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 } const ACTIVE_REFRESH_MS = 30_000 const CONTEXT_REQUEST_TIMEOUT_MS = 20_000 const AUTO_REFRESH_MIN_INTERVAL_MS = 10_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 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, }: 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 [mobileDetailsOpen, setMobileDetailsOpen] = useState(false) const requestSeq = useRef(0) const contextIdentityRef = useRef('') const inFlightRequestRef = useRef | null>(null) const inFlightIdentityRef = useRef(null) const lastAutoRefreshAtRef = useRef(0) const refresh = useCallback(async (mode: 'auto' | 'manual' = 'manual') => { if (!sessionId || draft) { setLoading(false) return } if (mode === 'auto' && !isDocumentVisible()) { setLoading(false) return } if (mode === 'auto' && Date.now() - lastAutoRefreshAtRef.current < AUTO_REFRESH_MIN_INTERVAL_MS) { return inFlightRequestRef.current ?? undefined } if (typeof sessionsApi.getInspection !== 'function') { setLoading(false) return } const activeSessionId = sessionId const activeContextIdentity = `${activeSessionId}:${runtimeSelectionKey}` if (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 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 || activeContextIdentity !== contextIdentityRef.current) return setError(err instanceof Error ? err.message : String(err)) }) .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]) 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 (
{t('contextIndicator.title')}
{displayModel ?? t('contextIndicator.modelUnknown')}
{displayContext ? formatPercent(percentage) : '--'}
{displayContext ? ( <>
{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')}
)}
{compact && ( setMobileDetailsOpen(false)} title={t('contextIndicator.title')} closeLabel={t('tabs.close')} ariaLabel={t('contextIndicator.title')} headerExtra={(
{displayModel ?? t('contextIndicator.modelUnknown')}
)} contentClassName="p-4" >
{displayContext ? formatPercent(percentage) : '--'}
{contextSource === 'estimate' && ( {t('contextIndicator.estimate')} )}
{displayContext ? (
{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)}
) : (
{isPendingContext ? t('contextIndicator.pendingDetail') : loading ? t('contextIndicator.loading') : t('contextIndicator.unavailableDetail')}
)} )}
) }