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) { 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) { 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(null) const [contextSource, setContextSource] = useState<'live' | 'estimate' | null>(null) const [loading, setLoading] = useState(() => shouldFetchContext(sessionId, draft)) const [error, setError] = useState(null) const [updatedAt, setUpdatedAt] = useState(null) const [inspectionModel, setInspectionModel] = useState(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 (
{t('contextIndicator.title')}
{displayModel ?? t('contextIndicator.modelUnknown')}
{context ? formatPercent(percentage) : '--'}
{context ? ( <>
{t('contextIndicator.used')}
{formatNumber(usedTokens)}
{t('contextIndicator.free')}
{formatNumber(freeTokens)}
{t('contextIndicator.window')}
{maxTokens > 0 ? formatNumber(maxTokens) : '--'}
{details.length > 0 && (
{details.map((category) => { const percent = maxTokens > 0 ? Math.max(0.5, Math.min(100, (category.tokens / maxTokens) * 100)) : 0 return (
{category.name} {formatNumber(category.tokens)}
) })}
)}
{formatUpdatedAt(updatedAt, t)} {contextSource === 'estimate' && ( {t('contextIndicator.estimate')} )}
) : isPendingContext ? (
{t('contextIndicator.pendingDetail')}
) : (
{loading ? t('contextIndicator.loading') : t('contextIndicator.unavailableDetail')}
)}
) }