fix: keep empty live contexts pending in desktop composer

A newly opened desktop session can return a live context snapshot before any
prompt content has been counted. When that snapshot is all zeroes, rendering it
as 0% makes the first real context update look like an unexplained jump. The
composer now keeps that empty live snapshot in the pending state while still
using its model metadata.

Constraint: Live context snapshots can be initialized before any user-visible context exists
Rejected: Render 0% for zero-token live snapshots | it repeats the misleading empty-session state
Confidence: high
Scope-risk: narrow
Directive: Do not show numeric context usage for empty zero-token sessions
Tested: cd desktop && bun run test -- pages.test.tsx
Tested: cd desktop && bun run lint
This commit is contained in:
程序员阿江(Relakkes) 2026-05-05 19:36:18 +08:00
parent 432a58d725
commit fb90957003
2 changed files with 100 additions and 15 deletions

View File

@ -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(<ActiveSession />)
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({

View File

@ -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({
}`}
>
<span className="relative grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full">
{loading && !context ? (
{loading && !displayContext ? (
<span className="absolute inset-[2px] rounded-full border-2 border-[var(--color-text-tertiary)] border-t-transparent motion-safe:animate-spin" />
) : (
<span
@ -189,7 +195,7 @@ export function ContextUsageIndicator({
<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)' }}
style={{ backgroundColor: displayContext ? strokeColor : 'var(--color-text-tertiary)' }}
/>
</span>
)}
@ -210,11 +216,11 @@ export function ContextUsageIndicator({
</div>
</div>
<div className="shrink-0 font-mono text-xl font-semibold text-[var(--color-text-primary)]">
{context ? formatPercent(percentage) : '--'}
{displayContext ? formatPercent(percentage) : '--'}
</div>
</div>
{context ? (
{displayContext ? (
<>
<div className="mt-4 grid grid-cols-2 gap-3 font-mono text-xs">
<div>