mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
The context usage indicator stayed at the pre-compact percentage (e.g.
100%) until the next API response arrived. Two stacked causes:
- The CLI anchors the displayed total to max(local estimate, last API
usage) so the meter never drops mid-turn — but preserved messages
(SM-compact / partial compact) still carried the pre-compact usage,
pinning the meter after compaction. buildPostCompactMessages now
zeroes token usage on preserved assistant copies, the established
stale placeholder that getCurrentUsage() skips, covering every
compaction path in one place. Originals are not mutated, so
transcript and cost accounting are unaffected.
- Right after compaction the CLI is often still busy, so the
indicator's refresh timed out ("Request timed out after 30s") and the
catch kept the stale context on screen with no later retry (auto
refresh is throttled to 10s and stops once the session goes idle).
compact_boundary now bumps a per-session compactCount; the indicator
force-refreshes on that nonce — bypassing the throttle and any
in-flight request that may still hold pre-compact data — and retries
once after 5s if the forced refresh fails.
Tested: bun test src/services/compact/
Tested: cd desktop && bun run test -- src/components/chat/ContextUsageIndicator.test.tsx src/stores/chatStore.test.ts
Tested: cd desktop && bun run lint
Confidence: high
Scope-risk: narrow
440 lines
20 KiB
TypeScript
440 lines
20 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { sessionsApi, type SessionContextSnapshot } from '../../api/sessions'
|
|
import { useTranslation } from '../../i18n'
|
|
import type { ChatState } from '../../types/chat'
|
|
import { MobileBottomSheet } from '../shared/MobileBottomSheet'
|
|
|
|
type Props = {
|
|
sessionId?: string
|
|
chatState: ChatState
|
|
messageCount: number
|
|
runtimeSelectionKey?: string
|
|
fallbackModelLabel?: string
|
|
draft?: boolean
|
|
compact?: boolean
|
|
/**
|
|
* Bump to force an immediate refresh that bypasses the auto-refresh
|
|
* throttle and any in-flight (possibly pre-compact) request. Used after
|
|
* context compaction so the meter recovers right away (#743).
|
|
*/
|
|
refreshNonce?: number
|
|
}
|
|
|
|
const ACTIVE_REFRESH_MS = 30_000
|
|
const CONTEXT_REQUEST_TIMEOUT_MS = 20_000
|
|
const AUTO_REFRESH_MIN_INTERVAL_MS = 10_000
|
|
// Right after a compaction the CLI may still be busy finishing the turn, so
|
|
// the forced refresh can time out — retry once instead of keeping the stale
|
|
// pre-compact percentage on screen.
|
|
const FORCED_REFRESH_RETRY_MS = 5_000
|
|
|
|
function formatNumber(value: number | undefined) {
|
|
return new Intl.NumberFormat().format(value ?? 0)
|
|
}
|
|
|
|
function formatPercent(value: number | undefined) {
|
|
const percent = Math.max(0, Math.min(100, value ?? 0))
|
|
return `${percent.toFixed(percent >= 10 || Number.isInteger(percent) ? 0 : 1)}%`
|
|
}
|
|
|
|
function formatUpdatedAt(timestamp: number | null, t: ReturnType<typeof useTranslation>) {
|
|
if (!timestamp) return t('contextIndicator.updatedUnknown')
|
|
const elapsedMs = Date.now() - timestamp
|
|
if (elapsedMs < 60_000) return t('contextIndicator.updatedNow')
|
|
const minutes = Math.max(1, Math.floor(elapsedMs / 60_000))
|
|
return t('contextIndicator.updatedMinutes', { count: minutes })
|
|
}
|
|
|
|
function pickUsedContextCategory(context: SessionContextSnapshot) {
|
|
const ignored = new Set(['free space', 'autocompact buffer'])
|
|
return context.categories
|
|
.filter((category) => category.tokens > 0 && !category.isDeferred && !ignored.has(category.name.toLowerCase()))
|
|
.sort((a, b) => b.tokens - a.tokens)
|
|
.slice(0, 4)
|
|
}
|
|
|
|
function firstNonEmpty(...values: Array<string | undefined | null>) {
|
|
return values.find((value) => typeof value === 'string' && value.trim().length > 0)?.trim()
|
|
}
|
|
|
|
function isCliNotRunningError(error: string | null) {
|
|
return error?.toLowerCase().includes('cli session is not running') ?? false
|
|
}
|
|
|
|
function isDocumentVisible() {
|
|
return typeof document === 'undefined' || document.visibilityState !== 'hidden'
|
|
}
|
|
|
|
function shouldFetchContext(sessionId: string | undefined, draft: boolean) {
|
|
return Boolean(sessionId) && !draft
|
|
}
|
|
|
|
export function ContextUsageIndicator({
|
|
sessionId,
|
|
chatState,
|
|
messageCount,
|
|
runtimeSelectionKey = '',
|
|
fallbackModelLabel,
|
|
draft = false,
|
|
compact = false,
|
|
refreshNonce = 0,
|
|
}: Props) {
|
|
const t = useTranslation()
|
|
const [context, setContext] = useState<SessionContextSnapshot | null>(null)
|
|
const [contextSource, setContextSource] = useState<'live' | 'estimate' | null>(null)
|
|
const [loading, setLoading] = useState(() => shouldFetchContext(sessionId, draft))
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [updatedAt, setUpdatedAt] = useState<number | null>(null)
|
|
const [inspectionModel, setInspectionModel] = useState<string | null>(null)
|
|
const [mobileDetailsOpen, setMobileDetailsOpen] = useState(false)
|
|
const requestSeq = useRef(0)
|
|
const contextIdentityRef = useRef('')
|
|
const inFlightRequestRef = useRef<Promise<boolean> | null>(null)
|
|
const inFlightIdentityRef = useRef<string | null>(null)
|
|
const lastAutoRefreshAtRef = useRef(0)
|
|
|
|
const refresh = useCallback(async (mode: 'auto' | 'manual' | 'force' = 'manual'): Promise<boolean> => {
|
|
if (!sessionId || draft) {
|
|
setLoading(false)
|
|
return false
|
|
}
|
|
if (mode === 'auto' && !isDocumentVisible()) {
|
|
setLoading(false)
|
|
return false
|
|
}
|
|
if (mode === 'auto' && Date.now() - lastAutoRefreshAtRef.current < AUTO_REFRESH_MIN_INTERVAL_MS) {
|
|
return inFlightRequestRef.current ?? false
|
|
}
|
|
if (typeof sessionsApi.getInspection !== 'function') {
|
|
setLoading(false)
|
|
return false
|
|
}
|
|
const activeSessionId = sessionId
|
|
const activeContextIdentity = `${activeSessionId}:${runtimeSelectionKey}`
|
|
// 'force' must not reuse an in-flight request: one started just before a
|
|
// compact boundary would resolve with the pre-compact context.
|
|
if (mode !== 'force' && inFlightRequestRef.current && inFlightIdentityRef.current === activeContextIdentity) {
|
|
return inFlightRequestRef.current
|
|
}
|
|
const seq = requestSeq.current + 1
|
|
requestSeq.current = seq
|
|
if (mode === 'auto') lastAutoRefreshAtRef.current = Date.now()
|
|
setLoading(true)
|
|
setError(null)
|
|
const request = sessionsApi.getInspection(activeSessionId, {
|
|
includeContext: true,
|
|
contextOnly: true,
|
|
timeout: CONTEXT_REQUEST_TIMEOUT_MS,
|
|
})
|
|
.then((inspection) => {
|
|
if (seq !== requestSeq.current || activeContextIdentity !== contextIdentityRef.current) return false
|
|
const nextContext = inspection.context ?? inspection.contextEstimate ?? null
|
|
const nextSource = inspection.context ? 'live' : inspection.contextEstimate ? 'estimate' : null
|
|
const usageModel = inspection.usage?.models.find((model) => firstNonEmpty(model.displayName, model.model)) ?? null
|
|
setContext(nextContext)
|
|
setContextSource(nextSource)
|
|
setInspectionModel(firstNonEmpty(
|
|
inspection.context?.model,
|
|
inspection.contextEstimate?.model,
|
|
inspection.status?.model,
|
|
usageModel?.displayName,
|
|
usageModel?.model,
|
|
) ?? null)
|
|
setError(nextContext ? null : inspection.errors?.context ?? null)
|
|
setUpdatedAt(Date.now())
|
|
return nextContext !== null
|
|
})
|
|
.catch((err) => {
|
|
if (seq !== requestSeq.current || activeContextIdentity !== contextIdentityRef.current) return false
|
|
setError(err instanceof Error ? err.message : String(err))
|
|
return false
|
|
})
|
|
.finally(() => {
|
|
if (inFlightRequestRef.current === request) {
|
|
inFlightRequestRef.current = null
|
|
inFlightIdentityRef.current = null
|
|
}
|
|
if (seq === requestSeq.current) setLoading(false)
|
|
})
|
|
inFlightRequestRef.current = request
|
|
inFlightIdentityRef.current = activeContextIdentity
|
|
return request
|
|
}, [draft, runtimeSelectionKey, sessionId])
|
|
|
|
// After a compaction the context shrinks server-side but nothing else
|
|
// re-reads it promptly (auto refreshes are throttled and stop once the
|
|
// session goes idle), leaving the pre-compact percentage on screen (#743).
|
|
// Force a fresh request, and retry once if the CLI was still busy.
|
|
const lastRefreshNonceRef = useRef(refreshNonce)
|
|
useEffect(() => {
|
|
if (refreshNonce === lastRefreshNonceRef.current) return
|
|
lastRefreshNonceRef.current = refreshNonce
|
|
let cancelled = false
|
|
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
|
void refresh('force').then((ok) => {
|
|
if (ok || cancelled) return
|
|
retryTimer = setTimeout(() => {
|
|
void refresh('force')
|
|
}, FORCED_REFRESH_RETRY_MS)
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
if (retryTimer) clearTimeout(retryTimer)
|
|
}
|
|
}, [refresh, refreshNonce])
|
|
|
|
useEffect(() => {
|
|
const contextIdentity = `${sessionId}:${runtimeSelectionKey}`
|
|
const identityChanged = contextIdentityRef.current !== contextIdentity
|
|
contextIdentityRef.current = contextIdentity
|
|
if (identityChanged) {
|
|
requestSeq.current += 1
|
|
lastAutoRefreshAtRef.current = 0
|
|
setContext(null)
|
|
setContextSource(null)
|
|
setError(null)
|
|
setUpdatedAt(null)
|
|
setInspectionModel(null)
|
|
}
|
|
void refresh('auto')
|
|
}, [messageCount, refresh, runtimeSelectionKey, sessionId])
|
|
|
|
useEffect(() => {
|
|
if (typeof document === 'undefined') return
|
|
const refreshIfVisible = () => {
|
|
if (!isDocumentVisible()) return
|
|
void refresh('auto')
|
|
}
|
|
document.addEventListener('visibilitychange', refreshIfVisible)
|
|
return () => document.removeEventListener('visibilitychange', refreshIfVisible)
|
|
}, [refresh])
|
|
|
|
useEffect(() => {
|
|
if (chatState === 'idle') return
|
|
const timer = setInterval(() => {
|
|
void refresh('auto')
|
|
}, ACTIVE_REFRESH_MS)
|
|
return () => clearInterval(timer)
|
|
}, [chatState, messageCount, refresh])
|
|
|
|
const details = useMemo(() => {
|
|
if (!context) return []
|
|
return pickUsedContextCategory(context)
|
|
}, [context])
|
|
|
|
const displayContext = context
|
|
const hasPlaceholderContext = !displayContext && (
|
|
draft || (!loading && messageCount === 0 && (!error || isCliNotRunningError(error)))
|
|
)
|
|
const isPendingContext = hasPlaceholderContext && !displayContext
|
|
const percentage = displayContext ? Math.max(0, Math.min(100, displayContext.percentage)) : 0
|
|
const usedTokens = displayContext?.totalTokens ?? 0
|
|
const maxTokens = displayContext?.rawMaxTokens ?? 0
|
|
const freeTokens = Math.max(0, maxTokens - usedTokens)
|
|
const strokeColor = percentage >= 90
|
|
? 'var(--color-error)'
|
|
: percentage >= 75
|
|
? 'var(--color-warning)'
|
|
: 'var(--color-secondary)'
|
|
const ringStyle = {
|
|
background: displayContext
|
|
? `conic-gradient(${strokeColor} ${percentage * 3.6}deg, var(--color-surface-container-high) 0deg)`
|
|
: 'var(--color-surface-container-high)',
|
|
}
|
|
const displayPercent = displayContext ? formatPercent(percentage) : '--'
|
|
const displayModel = firstNonEmpty(context?.model, inspectionModel, fallbackModelLabel)
|
|
const ariaLabel = displayContext
|
|
? t('contextIndicator.ariaLabel', { percent: formatPercent(percentage) })
|
|
: isPendingContext
|
|
? t('contextIndicator.pendingAria')
|
|
: loading
|
|
? t('contextIndicator.loadingAria')
|
|
: t('contextIndicator.unavailableAria')
|
|
|
|
return (
|
|
<div className="group/context relative pointer-events-auto">
|
|
<button
|
|
type="button"
|
|
aria-label={ariaLabel}
|
|
onClick={() => {
|
|
if (compact) {
|
|
setMobileDetailsOpen(true)
|
|
}
|
|
void refresh('manual')
|
|
}}
|
|
title={t('contextIndicator.title')}
|
|
data-testid="context-usage-indicator"
|
|
className={`flex h-8 shrink-0 items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container)] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-container-lowest)] ${
|
|
compact ? 'px-2' : 'px-2.5'
|
|
}`}
|
|
>
|
|
<span className="relative grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full">
|
|
{loading && !displayContext ? (
|
|
<span className="absolute inset-[2px] rounded-full border-2 border-[var(--color-text-tertiary)] border-t-transparent motion-safe:animate-spin" />
|
|
) : (
|
|
<span
|
|
className="relative grid h-[18px] w-[18px] place-items-center rounded-full"
|
|
style={ringStyle}
|
|
>
|
|
<span className="absolute inset-[3px] rounded-full bg-[var(--color-surface-container-lowest)]" />
|
|
<span
|
|
className="relative h-[5px] w-[5px] rounded-full"
|
|
style={{ backgroundColor: displayContext ? strokeColor : 'var(--color-text-tertiary)' }}
|
|
/>
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="font-mono text-[11px] font-semibold tabular-nums">
|
|
{displayPercent}
|
|
</span>
|
|
</button>
|
|
|
|
<div className={`pointer-events-none absolute bottom-full right-0 z-40 mb-2 w-[320px] max-w-[calc(100vw-2rem)] translate-y-1 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-4 text-left opacity-0 shadow-[var(--shadow-dropdown)] transition-all duration-150 group-hover/context:translate-y-0 group-hover/context:opacity-100 group-focus-within/context:translate-y-0 group-focus-within/context:opacity-100 ${
|
|
compact ? 'hidden' : ''
|
|
}`}>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
|
|
{t('contextIndicator.title')}
|
|
</div>
|
|
<div className="mt-1 truncate text-sm font-semibold text-[var(--color-text-primary)]">
|
|
{displayModel ?? t('contextIndicator.modelUnknown')}
|
|
</div>
|
|
</div>
|
|
<div className="shrink-0 font-mono text-xl font-semibold text-[var(--color-text-primary)]">
|
|
{displayContext ? formatPercent(percentage) : '--'}
|
|
</div>
|
|
</div>
|
|
|
|
{displayContext ? (
|
|
<>
|
|
<div className="mt-4 grid grid-cols-2 gap-3 font-mono text-xs">
|
|
<div>
|
|
<div className="text-[var(--color-text-tertiary)]">{t('contextIndicator.used')}</div>
|
|
<div className="mt-1 text-[var(--color-text-primary)]">{formatNumber(usedTokens)}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-[var(--color-text-tertiary)]">{t('contextIndicator.free')}</div>
|
|
<div className="mt-1 text-[var(--color-text-primary)]">{formatNumber(freeTokens)}</div>
|
|
</div>
|
|
<div className="col-span-2">
|
|
<div className="text-[var(--color-text-tertiary)]">{t('contextIndicator.window')}</div>
|
|
<div className="mt-1 text-[var(--color-text-primary)]">{maxTokens > 0 ? formatNumber(maxTokens) : '--'}</div>
|
|
</div>
|
|
</div>
|
|
{details.length > 0 && (
|
|
<div className="mt-4 space-y-2">
|
|
{details.map((category) => {
|
|
const percent = maxTokens > 0 ? Math.max(0.5, Math.min(100, (category.tokens / maxTokens) * 100)) : 0
|
|
return (
|
|
<div key={category.name}>
|
|
<div className="flex items-center justify-between gap-3 text-xs">
|
|
<span className="min-w-0 truncate text-[var(--color-text-secondary)]">{category.name}</span>
|
|
<span className="shrink-0 font-mono text-[var(--color-text-tertiary)]">{formatNumber(category.tokens)}</span>
|
|
</div>
|
|
<div className="mt-1 h-1 overflow-hidden rounded-full bg-[var(--color-surface-container)]">
|
|
<div className="h-full rounded-full" style={{ width: `${percent}%`, backgroundColor: category.color }} />
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
<div className="mt-4 text-[11px] text-[var(--color-text-tertiary)]">
|
|
{formatUpdatedAt(updatedAt, t)}
|
|
{contextSource === 'estimate' && (
|
|
<span className="ml-2 inline-flex rounded-full border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]">
|
|
{t('contextIndicator.estimate')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : isPendingContext ? (
|
|
<div className="mt-4 text-sm leading-6 text-[var(--color-text-secondary)]">
|
|
{t('contextIndicator.pendingDetail')}
|
|
</div>
|
|
) : (
|
|
<div className="mt-4 text-sm leading-6 text-[var(--color-text-secondary)]">
|
|
{loading ? t('contextIndicator.loading') : t('contextIndicator.unavailableDetail')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{compact && (
|
|
<MobileBottomSheet
|
|
open={mobileDetailsOpen}
|
|
onClose={() => setMobileDetailsOpen(false)}
|
|
title={t('contextIndicator.title')}
|
|
closeLabel={t('tabs.close')}
|
|
ariaLabel={t('contextIndicator.title')}
|
|
headerExtra={(
|
|
<div className="truncate text-base font-semibold text-[var(--color-text-primary)]">
|
|
{displayModel ?? t('contextIndicator.modelUnknown')}
|
|
</div>
|
|
)}
|
|
contentClassName="p-4"
|
|
>
|
|
<div className="flex items-end justify-between gap-4">
|
|
<div className="font-mono text-4xl font-semibold text-[var(--color-text-primary)]">
|
|
{displayContext ? formatPercent(percentage) : '--'}
|
|
</div>
|
|
{contextSource === 'estimate' && (
|
|
<span className="mb-1 rounded-full border border-[var(--color-border)] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
|
|
{t('contextIndicator.estimate')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{displayContext ? (
|
|
<div className="mt-5">
|
|
<div className="grid grid-cols-3 gap-2 font-mono text-xs">
|
|
<div className="rounded-xl bg-[var(--color-surface-container)] p-3">
|
|
<div className="text-[var(--color-text-tertiary)]">{t('contextIndicator.used')}</div>
|
|
<div className="mt-1 text-[var(--color-text-primary)]">{formatNumber(usedTokens)}</div>
|
|
</div>
|
|
<div className="rounded-xl bg-[var(--color-surface-container)] p-3">
|
|
<div className="text-[var(--color-text-tertiary)]">{t('contextIndicator.free')}</div>
|
|
<div className="mt-1 text-[var(--color-text-primary)]">{formatNumber(freeTokens)}</div>
|
|
</div>
|
|
<div className="rounded-xl bg-[var(--color-surface-container)] p-3">
|
|
<div className="text-[var(--color-text-tertiary)]">{t('contextIndicator.window')}</div>
|
|
<div className="mt-1 text-[var(--color-text-primary)]">{maxTokens > 0 ? formatNumber(maxTokens) : '--'}</div>
|
|
</div>
|
|
</div>
|
|
{details.length > 0 && (
|
|
<div className="mt-5 space-y-3">
|
|
{details.map((category) => {
|
|
const percent = maxTokens > 0 ? Math.max(0.5, Math.min(100, (category.tokens / maxTokens) * 100)) : 0
|
|
return (
|
|
<div key={category.name}>
|
|
<div className="flex items-center justify-between gap-3 text-xs">
|
|
<span className="min-w-0 truncate text-[var(--color-text-secondary)]">{category.name}</span>
|
|
<span className="shrink-0 font-mono text-[var(--color-text-tertiary)]">{formatNumber(category.tokens)}</span>
|
|
</div>
|
|
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-[var(--color-surface-container)]">
|
|
<div className="h-full rounded-full" style={{ width: `${percent}%`, backgroundColor: category.color }} />
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
<div className="mt-4 text-[11px] text-[var(--color-text-tertiary)]">
|
|
{formatUpdatedAt(updatedAt, t)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="mt-5 rounded-xl bg-[var(--color-surface-container)] p-4 text-sm leading-6 text-[var(--color-text-secondary)]">
|
|
{isPendingContext
|
|
? t('contextIndicator.pendingDetail')
|
|
: loading
|
|
? t('contextIndicator.loading')
|
|
: t('contextIndicator.unavailableDetail')}
|
|
</div>
|
|
)}
|
|
</MobileBottomSheet>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|