fix(desktop): context gauge reads the session it is on, not the last turn

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.
This commit is contained in:
Brooklyn Nicholson
2026-08-14 18:33:43 -05:00
committed by brooklyn!
parent 50d98fc1f3
commit 25d1c8d740
3 changed files with 124 additions and 81 deletions
@@ -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<UsageStats, 'context_max' | 'context_percent' | 'context_used'>) => void
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
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<ContextBreakdown | null>(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<ContextBreakdown>('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({
))}
</ul>
{loading && <p className="text-[0.6875rem] text-muted-foreground">{copy.loading}</p>}
{loading && !categories.length && <p className="text-[0.6875rem] text-muted-foreground">{copy.loading}</p>}
{!loading && !categories.length && <p className="text-[0.6875rem] text-muted-foreground">{copy.empty}</p>}
</div>
@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react'
import type { ContextBreakdown } from '@/types/hermes'
interface ContextBreakdownOptions {
busy: boolean
enabled: boolean
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
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<ContextBreakdown>('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
}
}
@@ -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<UsageStats, 'context_max' | 'context_percent' | 'context_used'>) => {
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<UsageStats>(
() =>
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: (
<ContextUsagePanel
currentUsage={currentUsage}
onUsageSnapshot={publishContextUsage}
requestGateway={requestGateway}
sessionId={activeSessionId}
breakdown={contextBreakdown}
loading={contextBreakdownLoading}
usage={gaugeUsage}
/>
),
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,