mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
432a58d725
commit
fb90957003
@ -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({
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user