From 25d1c8d7406b934fcbd0489ac64e4d99dfc4dcf8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 14 Aug 2026 18:33:43 -0500 Subject: [PATCH] fix(desktop): context gauge reads the session it is on, not the last turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The statusbar gauge painted only what the backend reported as measured occupancy, which a session has none of until a turn runs in this process. Turning the gauge on mid-conversation, or resuming a chat, therefore showed nothing until the next message. Fetch session.context_breakdown as soon as the gauge is on screen instead of when its popover opens. It is the same read-only estimate the popover already used (chars/4 over the live prompt, tools and transcript — no provider call), and it reports the measured figure once the backend has one. The popover becomes presentational and reads the gauge s merged usage, so the bar and the panel cannot disagree. --- .../src/app/shell/context-usage-panel.tsx | 76 ++++--------------- .../app/shell/hooks/use-context-breakdown.ts | 63 +++++++++++++++ .../app/shell/hooks/use-statusbar-items.tsx | 66 +++++++++++----- 3 files changed, 124 insertions(+), 81 deletions(-) create mode 100644 apps/desktop/src/app/shell/hooks/use-context-breakdown.ts diff --git a/apps/desktop/src/app/shell/context-usage-panel.tsx b/apps/desktop/src/app/shell/context-usage-panel.tsx index 8689e8f29f..07e08cabb5 100644 --- a/apps/desktop/src/app/shell/context-usage-panel.tsx +++ b/apps/desktop/src/app/shell/context-usage-panel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useMemo } from 'react' import { useI18n } from '@/i18n' import { compactNumber } from '@/lib/format' @@ -6,70 +6,22 @@ import { cn } from '@/lib/utils' import type { ContextBreakdown, ContextUsageCategory, UsageStats } from '@/types/hermes' interface ContextUsagePanelProps { - currentUsage: UsageStats - onUsageSnapshot?: (usage: Pick) => void - requestGateway: (method: string, params?: Record) => Promise - sessionId: string | null + breakdown: ContextBreakdown | null + loading: boolean + usage: UsageStats } -export function ContextUsagePanel({ - currentUsage, - onUsageSnapshot, - requestGateway, - sessionId -}: ContextUsagePanelProps) { +/** Presentational: the breakdown is fetched by the statusbar (see + * `useContextBreakdown`) because the gauge's own label needs it, so the + * popover opens with its numbers already in hand. `usage` is the gauge's + * merged figure — measured occupancy when the backend has it, the estimate + * otherwise — so the header and the bar can never disagree. */ +export function ContextUsagePanel({ breakdown, loading, usage }: ContextUsagePanelProps) { const { t } = useI18n() const copy = t.shell.statusbar.contextUsagePanel - const [breakdown, setBreakdown] = useState(null) - const [loading, setLoading] = useState(false) - const onUsageSnapshotRef = useRef(onUsageSnapshot) - onUsageSnapshotRef.current = onUsageSnapshot - - useEffect(() => { - if (!sessionId) { - setBreakdown(null) - setLoading(false) - - return - } - - let cancelled = false - setLoading(true) - - void requestGateway('session.context_breakdown', { session_id: sessionId }) - .then(data => { - if (!cancelled) { - setBreakdown(data) - onUsageSnapshotRef.current?.({ - context_max: data.context_max, - context_percent: data.context_percent, - context_used: data.context_used - }) - } - }) - .catch(() => { - if (!cancelled) { - setBreakdown(null) - } - }) - .finally(() => { - if (!cancelled) { - setLoading(false) - } - }) - - return () => { - cancelled = true - } - }, [requestGateway, sessionId]) - - const contextMax = breakdown?.context_max ?? currentUsage.context_max ?? 0 - const contextUsed = breakdown?.context_used ?? currentUsage.context_used ?? 0 - - const contextPercent = Math.max( - 0, - Math.min(100, Math.round(breakdown?.context_percent ?? currentUsage.context_percent ?? 0)) - ) + const contextMax = usage.context_max ?? 0 + const contextUsed = usage.context_used ?? 0 + const contextPercent = Math.max(0, Math.min(100, Math.round(usage.context_percent ?? 0))) const categories = useMemo( () => @@ -110,7 +62,7 @@ export function ContextUsagePanel({ ))} - {loading &&

{copy.loading}

} + {loading && !categories.length &&

{copy.loading}

} {!loading && !categories.length &&

{copy.empty}

} diff --git a/apps/desktop/src/app/shell/hooks/use-context-breakdown.ts b/apps/desktop/src/app/shell/hooks/use-context-breakdown.ts new file mode 100644 index 0000000000..5170d047b6 --- /dev/null +++ b/apps/desktop/src/app/shell/hooks/use-context-breakdown.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react' + +import type { ContextBreakdown } from '@/types/hermes' + +interface ContextBreakdownOptions { + busy: boolean + enabled: boolean + requestGateway: (method: string, params?: Record) => Promise + sessionId: null | string +} + +/** The focused session's context breakdown, fetched as soon as the statusbar + * gauge is on screen rather than when its popover opens. + * + * The backend only reports measured context occupancy (`last_prompt_tokens`) + * once a turn has run in THIS process, so a resumed session reports none — + * which is why turning the gauge on used to do nothing at all until you sent + * a message. `session.context_breakdown` estimates the same figure from the + * live system prompt + tools + transcript, so it answers for a session that + * hasn't spoken yet. It is a read-only chars/4 pass: no provider call, no + * prompt-cache impact. + * + * Refetches when the focused session changes and when a turn ends (the + * transcript just grew). Held keyed by the session it describes so switching + * sessions drops the previous numbers instead of painting them under the new + * session's name. */ +export function useContextBreakdown({ busy, enabled, requestGateway, sessionId }: ContextBreakdownOptions) { + const [fetched, setFetched] = useState<{ breakdown: ContextBreakdown; sessionId: string } | null>(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + // Mid-turn the transcript changes on every delta and the gateway already + // streams measured usage, so an estimate would be both stale and wasteful. + if (!enabled || !sessionId || busy) { + return + } + + let cancelled = false + setLoading(true) + + void requestGateway('session.context_breakdown', { session_id: sessionId }) + .then(breakdown => { + if (!cancelled && breakdown) { + setFetched({ breakdown, sessionId }) + } + }) + .catch(() => undefined) + .finally(() => { + if (!cancelled) { + setLoading(false) + } + }) + + return () => { + cancelled = true + } + }, [busy, enabled, requestGateway, sessionId]) + + return { + breakdown: fetched && fetched.sessionId === sessionId ? fetched.breakdown : null, + loading + } +} diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 26c33b094f..cc7a704206 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -1,10 +1,11 @@ import { useStore } from '@nanostores/react' -import { useCallback, useMemo } from 'react' +import { useMemo } from 'react' import type { CommandCenterSection } from '@/app/command-center' import { useApprovalModeStatusbarItem } from '@/app/shell/approval-mode-menu' import { ContextUsagePanel } from '@/app/shell/context-usage-panel' import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' +import { useContextBreakdown } from '@/app/shell/hooks/use-context-breakdown' import { $paneVisible, togglePaneVisible } from '@/components/pane-shell/tree/store' import { Codicon } from '@/components/ui/codicon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' @@ -31,10 +32,10 @@ import { $sessionStartedAt, $turnStartedAt, idsShareLineage, - sessionMatchesStoredId, - setCurrentUsage + sessionMatchesStoredId } from '@/store/session' import { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId } from '@/store/session-states' +import { $statusbarHiddenIds } from '@/store/statusbar-prefs' import { $subagentsBySession, activeSubagentCount, failedSubagentCount } from '@/store/subagents' import { $gatewayRestarting } from '@/store/system-actions' import { @@ -50,7 +51,7 @@ import type { StatusResponse, UsageStats } from '@/types/hermes' import { CRON_ROUTE, SETTINGS_ROUTE, WEBHOOKS_ROUTE } from '../../routes' import type { StatusbarItem } from '../statusbar-controls' -const EMPTY_USAGE = { calls: 0, input: 0, output: 0, total: 0 } as const +const EMPTY_USAGE: UsageStats = { calls: 0, input: 0, output: 0, total: 0 } interface StatusbarItemsOptions { agentsOpen: boolean @@ -223,16 +224,45 @@ export function useStatusbarItems({ ? focusedRowStartedAt * 1000 : null - const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage]) - const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage]) + // The backend only knows a session's MEASURED occupancy once a turn has run + // in this process, so a resumed conversation reports none and the gauge had + // nothing to paint — turning it on looked like it did nothing until you sent + // a message. Estimate from the live prompt + transcript instead, on the same + // read-only RPC the popover uses, so the readout is right the moment it's on + // screen. Gated on the gauge being shown — the bar itself is unmounted while + // toggled off, so this covers the rest. + const contextItemHidden = useStore($statusbarHiddenIds).includes('context-usage') - const publishContextUsage = useCallback( - (snapshot: Pick) => { - setCurrentUsage(current => ({ ...current, ...snapshot })) - }, - [] + const { breakdown: contextBreakdown, loading: contextBreakdownLoading } = useContextBreakdown({ + busy, + enabled: !contextItemHidden, + requestGateway, + sessionId: activeSessionId + }) + + // The breakdown wins whenever we have one, for two reasons: it reports the + // MEASURED occupancy once the backend has it (falling back to the estimate + // only before that), and it is keyed to the session it describes. The global + // `$currentUsage` is neither — a resumed session reports no context fields, + // and the store merges rather than replaces, so the PREVIOUS session's gauge + // numbers survive the switch. Mid-turn there's no breakdown by design and + // the streamed usage carries the gauge. + const gaugeUsage = useMemo( + () => + contextBreakdown + ? { + ...currentUsage, + context_max: contextBreakdown.context_max, + context_percent: contextBreakdown.context_percent, + context_used: contextBreakdown.context_used + } + : currentUsage, + [contextBreakdown, currentUsage] ) + const contextUsage = useMemo(() => usageContextLabel(gaugeUsage), [gaugeUsage]) + const contextBar = useMemo(() => contextBarLabel(gaugeUsage), [gaugeUsage]) + const approvalModeItem = useApprovalModeStatusbarItem(activeGatewayProfile, requestGateway) const gatewayMenuContent = useMemo( @@ -535,10 +565,9 @@ export function useStatusbarItems({ menuClassName: 'w-auto border-(--ui-stroke-secondary) p-0', menuContent: ( ), toggleLabel: copy.toggleContextUsage, @@ -572,18 +601,17 @@ export function useStatusbarItems({ ...(backendVersionItem ? [backendVersionItem] : []) ], [ - activeSessionId, approvalModeItem, backendVersionItem, busy, chatOpen, clientVersionItem, contextBar, + contextBreakdown, + contextBreakdownLoading, contextUsage, copy, - currentUsage, - publishContextUsage, - requestGateway, + gaugeUsage, sessionStartedAt, gatewayState, terminalShowing,