diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index fd14d03c..221d760f 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -765,7 +765,7 @@ describe('Content-only pages render without errors', () => { useChatStore.setState({ sessions: {} }) }) - it('ActiveSession treats an empty idle session without a running CLI as a 0% placeholder', async () => { + it('ActiveSession treats an empty idle session without a running CLI as pending context', async () => { const SESSION_ID = 'context-empty-idle-session' vi.mocked(sessionsApi.getInspection).mockResolvedValueOnce({ active: false, @@ -833,6 +833,85 @@ describe('Content-only pages render without errors', () => { useChatStore.setState({ sessions: {} }) }) + it('ActiveSession treats an empty live zero-token context as pending instead of 0%', async () => { + const SESSION_ID = 'context-empty-live-session' + vi.mocked(sessionsApi.getInspection).mockResolvedValueOnce({ + active: true, + status: { + sessionId: SESSION_ID, + workDir: '/workspace/project', + cwd: '/workspace/project', + permissionMode: 'bypassPermissions', + model: 'kimi-k2.6', + }, + context: { + categories: [ + { name: 'Free space', tokens: 128_000, color: '#9B928C', isDeferred: true }, + ], + totalTokens: 0, + maxTokens: 128_000, + rawMaxTokens: 128_000, + percentage: 0, + gridRows: [], + model: 'kimi-k2.6', + memoryFiles: [], + mcpTools: [], + agents: [], + }, + }) + + useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID }) + useSessionStore.setState({ + sessions: [{ + id: SESSION_ID, + title: 'Test', + createdAt: '2026-04-10T00:00:00.000Z', + modifiedAt: '2026-04-10T00:00:00.000Z', + messageCount: 0, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: SESSION_ID, + isLoading: false, + error: null, + }) + useChatStore.setState({ + sessions: { + [SESSION_ID]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + render() + + const indicator = await screen.findByLabelText('Context usage not calculated') + expect(indicator).toHaveTextContent('--') + expect(screen.queryByLabelText('Context usage 0%')).not.toBeInTheDocument() + expect(screen.getAllByText('kimi-k2.6').length).toBeGreaterThan(0) + expect(screen.getByText('Context usage will be calculated after the session starts.')).toBeInTheDocument() + + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) + useChatStore.setState({ sessions: {} }) + }) + it('ActiveSession shows context estimate during compaction or reconnect fallback', async () => { const SESSION_ID = 'context-estimate-session' vi.mocked(sessionsApi.getInspection).mockResolvedValueOnce({ diff --git a/desktop/src/components/chat/ContextUsageIndicator.tsx b/desktop/src/components/chat/ContextUsageIndicator.tsx index bb9e7f7e..9bae4ac1 100644 --- a/desktop/src/components/chat/ContextUsageIndicator.tsx +++ b/desktop/src/components/chat/ContextUsageIndicator.tsx @@ -53,6 +53,11 @@ function shouldFetchContext(sessionId: string | undefined, draft: boolean) { return Boolean(sessionId) && !draft } +function isEmptyContextSnapshot(context: SessionContextSnapshot | null, messageCount: number) { + if (!context || messageCount > 0) return false + return context.totalTokens === 0 && context.percentage === 0 +} + export function ContextUsageIndicator({ sessionId, chatState, @@ -134,17 +139,18 @@ export function ContextUsageIndicator({ }, [chatState, messageCount, refresh]) const details = useMemo(() => { - if (!context) return [] + if (!context || isEmptyContextSnapshot(context, messageCount)) return [] return pickUsedContextCategory(context) - }, [context]) + }, [context, messageCount]) - const hasPlaceholderContext = !context && ( + const displayContext = isEmptyContextSnapshot(context, messageCount) ? null : context + const hasPlaceholderContext = !displayContext && ( 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 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)' @@ -152,13 +158,13 @@ export function ContextUsageIndicator({ ? 'var(--color-warning)' : 'var(--color-secondary)' const ringStyle = { - background: context + background: displayContext ? `conic-gradient(${strokeColor} ${percentage * 3.6}deg, var(--color-surface-container-high) 0deg)` : 'var(--color-surface-container-high)', } - const displayPercent = context ? formatPercent(percentage) : '--' + const displayPercent = displayContext ? formatPercent(percentage) : '--' const displayModel = firstNonEmpty(context?.model, inspectionModel, fallbackModelLabel) - const ariaLabel = context + const ariaLabel = displayContext ? t('contextIndicator.ariaLabel', { percent: formatPercent(percentage) }) : isPendingContext ? t('contextIndicator.pendingAria') @@ -179,7 +185,7 @@ export function ContextUsageIndicator({ }`} > - {loading && !context ? ( + {loading && !displayContext ? ( ) : ( )} @@ -210,11 +216,11 @@ export function ContextUsageIndicator({
- {context ? formatPercent(percentage) : '--'} + {displayContext ? formatPercent(percentage) : '--'}
- {context ? ( + {displayContext ? ( <>