cc-haha/desktop/src/components/chat/ContextUsageIndicator.tsx
程序员阿江(Relakkes) e4f9825bc1 fix: avoid misleading context loading on empty desktop sessions
The desktop composer now treats draft and not-yet-running sessions as pending
until a real CLI context snapshot exists. Context inspection has a lightweight
context-only path so the composer and /context panel do not wait on unrelated
usage or MCP status work, and desktop boot no longer blocks the first shell
render on tab restoration.

Constraint: Empty composer state has no live CLI context to inspect until a session starts
Rejected: Display 0% before live context exists | it implied one user message consumed the fixed prompt/tool baseline
Confidence: high
Scope-risk: moderate
Directive: Do not show numeric context usage without a real context snapshot
Tested: cd desktop && bun run test -- pages.test.tsx
Tested: cd desktop && bun run lint
Tested: bun run check:desktop
Tested: bun run check:server
Tested: Browser reload at http://127.0.0.1:5174 with cleared open-tabs showed pending context and no /inspection request
2026-05-05 18:25:53 +08:00

273 lines
12 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'
type Props = {
sessionId?: string
chatState: ChatState
messageCount: number
runtimeSelectionKey?: string
fallbackModelLabel?: string
draft?: boolean
compact?: boolean
}
const ACTIVE_REFRESH_MS = 30_000
const CONTEXT_REQUEST_TIMEOUT_MS = 20_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 shouldFetchContext(sessionId: string | undefined, draft: boolean) {
return Boolean(sessionId) && !draft
}
export function ContextUsageIndicator({
sessionId,
chatState,
messageCount,
runtimeSelectionKey = '',
fallbackModelLabel,
draft = false,
compact = false,
}: 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 requestSeq = useRef(0)
const contextIdentityRef = useRef('')
const refresh = useCallback(async () => {
if (!sessionId || draft) {
setLoading(false)
return
}
const activeSessionId = sessionId
const seq = requestSeq.current + 1
requestSeq.current = seq
setLoading(true)
setError(null)
try {
const inspection = await sessionsApi.getInspection(activeSessionId, {
includeContext: true,
contextOnly: true,
timeout: CONTEXT_REQUEST_TIMEOUT_MS,
})
if (seq !== requestSeq.current) return
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())
} catch (err) {
if (seq !== requestSeq.current) return
setError(err instanceof Error ? err.message : String(err))
} finally {
if (seq === requestSeq.current) setLoading(false)
}
}, [draft, sessionId])
useEffect(() => {
const contextIdentity = `${sessionId}:${runtimeSelectionKey}`
const identityChanged = contextIdentityRef.current !== contextIdentity
contextIdentityRef.current = contextIdentity
if (identityChanged) {
setContext(null)
setContextSource(null)
setError(null)
setUpdatedAt(null)
setInspectionModel(null)
}
void refresh()
}, [messageCount, refresh, runtimeSelectionKey, sessionId])
useEffect(() => {
if (chatState === 'idle') return
const timer = setInterval(() => {
void refresh()
}, ACTIVE_REFRESH_MS)
return () => clearInterval(timer)
}, [chatState, messageCount, refresh])
const details = useMemo(() => {
if (!context) return []
return pickUsedContextCategory(context)
}, [context])
const hasPlaceholderContext = !context && (
draft || (!loading && messageCount === 0 && (!error || isCliNotRunningError(error)))
)
const isPendingContext = hasPlaceholderContext && !context
const percentage = context ? Math.max(0, Math.min(100, context.percentage)) : 0
const usedTokens = context?.totalTokens ?? 0
const maxTokens = context?.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: context
? `conic-gradient(${strokeColor} ${percentage * 3.6}deg, var(--color-surface-container-high) 0deg)`
: 'var(--color-surface-container-high)',
}
const displayPercent = context ? formatPercent(percentage) : '--'
const displayModel = firstNonEmpty(context?.model, inspectionModel, fallbackModelLabel)
const ariaLabel = context
? 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={() => { void refresh() }}
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 && !context ? (
<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: context ? 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">
<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)]">
{context ? formatPercent(percentage) : '--'}
</div>
</div>
{context ? (
<>
<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>
</div>
)
}