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:
committed by
brooklyn!
parent
50d98fc1f3
commit
25d1c8d740
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user