diff --git a/docs/design/web-shell-thinking-and-tool-progress.md b/docs/design/web-shell-thinking-and-tool-progress.md new file mode 100644 index 00000000000..b855f7c28f5 --- /dev/null +++ b/docs/design/web-shell-thinking-and-tool-progress.md @@ -0,0 +1,17 @@ +# Web Shell compact mode and tool progress + +## Goal + +Update the existing Web Shell compact mode to hide transcript thinking without changing model behavior, make parallel tool summaries describe every active foreground tool until all tools finish, and keep thinking/tool elapsed times stable across transcript replay. + +## Design + +`App` keeps the existing `Ctrl+O` compact-mode shortcut, context, Help terminology, and `ui.compactMode` workspace-setting write. Compact mode no longer switches message bodies to their old condensed cards. Instead, `MessageList` removes thinking rows only from its rendered item list, leaving the transcript and model behavior unchanged. + +In compact mode, regular tool groups separated only by hidden thinking are merged within the same activity sequence. Outside compact mode, visible thinking preserves the original interleaved transcript order. User, assistant, system, plan, approval, agent, todo, and question UI boundaries remain separate. Running tool summaries are derived from all active foreground tools and reuse the existing tool descriptions. Completed summaries remain unchanged and appear only after no tool is active. Expanded tool rows reuse the existing tool-kind icons. + +Transcript blocks retain the first and latest daemon timestamps. When a block carries an authoritative pair with a positive elapsed interval, thinking and tool messages keep the daemon-measured duration but anchor it onto the client clock, so every start/end timestamp stays in one domain and remains comparable across tools. Live durations use the same projection, avoiding mixed-clock subtraction while still surviving transcript replay. Legacy and partial records without a usable daemon pair use the client-time pair. Consecutive thinking blocks merge regardless of which timing source produced them, accumulating each block's own duration. + +## Compatibility + +The existing compact-mode concept and persistence path remain unchanged. No new setting, URL parameter, public transcript prop, or `localStorage` key is introduced. The read-only `WebShellTranscript` remains outside compact mode. diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 6a615916d49..97ec814f96e 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -292,7 +292,7 @@ function applyDaemonTranscriptEvent( // those reasons; the post-reconnect `tool_call_update` stream // will deliver the real terminal status. if (event.reason === 'cancelled' || event.reason === 'error') { - propagateCancellationToInFlightTools(next); + propagateCancellationToInFlightTools(next, event); } break; case 'assistant.usage': @@ -376,7 +376,7 @@ function applyDaemonTranscriptEvent( // UIs don't show a tool spinning forever after a peer cancel. // Idempotent — safe if the daemon also later emits terminal // tool_call_update frames. - propagateCancellationToInFlightTools(next); + propagateCancellationToInFlightTools(next, event); if (event.reason !== 'forward_failed') { appendPromptCancelledBlock(next, event); } @@ -444,7 +444,7 @@ function handleStateResyncRequired( lastDeliveredId: event.lastDeliveredId, earliestAvailableId: event.earliestAvailableId, }; - propagateCancellationToInFlightTools(state); + propagateCancellationToInFlightTools(state, event); appendStatusBlock( state, 'error', @@ -498,11 +498,15 @@ function finalizeStreamingTextBlock( if (event?.eventId !== undefined) block.eventId = event.eventId; // Preserve the text event's own timestamp during history replay; later // finalize/status events can be much newer and would skew message times. - if ( - block.serverTimestamp === undefined && - event?.serverTimestamp !== undefined - ) { - block.serverTimestamp = event.serverTimestamp; + if (event?.serverTimestamp !== undefined) { + if (block.serverTimestamp === undefined) { + // Degraded-record fallback: the block was never stamped while + // streaming, so the terminator's stamp approximates its first + // observed time rather than being the true start. + block.serverTimestamp = event.serverTimestamp; + } else { + block.serverUpdatedAt = event.serverTimestamp; + } } } } @@ -650,7 +654,8 @@ function appendTextDelta( existing.updatedAt = state.now; if (event.eventId !== undefined) existing.eventId = event.eventId; if (event.serverTimestamp !== undefined) { - existing.serverTimestamp = event.serverTimestamp; + existing.serverTimestamp ??= event.serverTimestamp; + existing.serverUpdatedAt = event.serverTimestamp; } if ('meta' in event && event.meta) { existing.meta = { ...existing.meta, ...event.meta }; @@ -687,15 +692,15 @@ function appendTextDelta( if (parentId != null) { if (kind === 'assistant') { - clearActiveThoughtForParent(state, parentId); + clearActiveThoughtForParent(state, parentId, event); } if (kind === 'thought') { - clearActiveAssistantForParent(state, parentId); + clearActiveAssistantForParent(state, parentId, event); } } else { if (kind !== 'user') state.activeUserBlockId = undefined; - if (kind !== 'assistant') clearActiveAssistant(state); - if (kind !== 'thought') clearActiveThought(state); + if (kind !== 'assistant') clearActiveAssistant(state, event); + if (kind !== 'thought') clearActiveThought(state, event); } } @@ -776,6 +781,9 @@ function upsertToolBlock( } existing.updatedAt = state.now; if (event.eventId !== undefined) existing.eventId = event.eventId; + if (event.serverTimestamp !== undefined) { + existing.serverUpdatedAt = event.serverTimestamp; + } if (event.details) existing.details = event.details; if (compactTaskOutput) delete existing.content; else if (event.content !== undefined) existing.content = event.content; @@ -884,7 +892,10 @@ function upsertToolBlock( updatedAt: state.now, ...(event.eventId !== undefined ? { eventId: event.eventId } : {}), ...(event.serverTimestamp !== undefined - ? { serverTimestamp: event.serverTimestamp } + ? { + serverTimestamp: event.serverTimestamp, + serverUpdatedAt: event.serverTimestamp, + } : {}), ...(event.sourceRecordIds ? { sourceRecordIds: [...event.sourceRecordIds] } @@ -930,7 +941,7 @@ function upsertToolBlock( // never points at it. Effective-status keeps the pointer in sync // with what was actually written to the block. updateCurrentToolPointer(state, event.toolCallId, event.status ?? 'pending'); - clearActiveText(state, event.parentToolCallId); + clearActiveText(state, event.parentToolCallId, event); } function discardToolBlock( @@ -1019,6 +1030,7 @@ function findLatestInFlightToolCallId( */ function propagateCancellationToInFlightTools( state: DaemonTranscriptState, + event?: DaemonUiEvent, ): void { // Skip trimmed sentinels up front. Without this filter // each cancellation walked the entire historical tool-call index (which @@ -1033,6 +1045,9 @@ function propagateCancellationToInFlightTools( if (!IN_FLIGHT_TOOL_STATUSES.has(block.status)) continue; block.status = 'cancelled'; block.updatedAt = state.now; + if (event?.serverTimestamp !== undefined) { + block.serverUpdatedAt = event.serverTimestamp; + } } state.currentToolCallId = undefined; } @@ -1068,7 +1083,7 @@ function appendShellBlock( ...(event.stream ? { stream: event.stream } : {}), }; appendBlock(state, block); - clearActiveText(state); + clearActiveText(state, undefined, event); } function appendUserShellBlock( @@ -1113,7 +1128,7 @@ function appendUserShellBlock( }; state.pendingUserShellCommand = undefined; appendBlock(state, block); - clearActiveText(state); + clearActiveText(state, undefined, event); } function upsertPermissionBlock( @@ -1155,7 +1170,7 @@ function upsertPermissionBlock( }; appendBlock(state, block); state.permissionBlockByRequestId[event.requestId] = block.id; - clearActiveText(state); + clearActiveText(state, undefined, event); } function resolvePermissionBlock( @@ -1208,7 +1223,7 @@ function resolvePermissionBlock( }; appendBlock(state, block); state.permissionBlockByRequestId[event.requestId] = block.id; - clearActiveText(state); + clearActiveText(state, undefined, event); } function appendStatusBlock( @@ -1263,7 +1278,7 @@ function appendStatusBlock( : {}), }; appendBlock(state, block); - if (opts.clearActiveText !== false) clearActiveText(state); + if (opts.clearActiveText !== false) clearActiveText(state, undefined, event); // Opt-out only protects the streaming assistant/thought block; the user // pointer must still reset, otherwise a later mergeable user.text.delta // (e.g. a peer client's prompt echo) appends onto the command echo block. @@ -1287,7 +1302,7 @@ function appendPromptCancelledBlock( : {}), }; appendBlock(state, block); - clearActiveText(state); + clearActiveText(state, undefined, event); } function createTextBlock( @@ -1308,7 +1323,9 @@ function createTextBlock( createdAt: state.now, updatedAt: state.now, ...(eventId !== undefined ? { eventId } : {}), - ...(serverTimestamp !== undefined ? { serverTimestamp } : {}), + ...(serverTimestamp !== undefined + ? { serverTimestamp, serverUpdatedAt: serverTimestamp } + : {}), ...(sourceRecordIds ? { sourceRecordIds: [...sourceRecordIds] } : {}), ...(meta ? { meta: { ...meta } } : {}), }; @@ -1609,12 +1626,17 @@ function allocateBlockId(state: DaemonTranscriptState, prefix: string): string { function clearActiveText( state: DaemonTranscriptState, parentToolCallId?: string, + event?: DaemonUiEvent, ): void { + // Terminator events close the streaming block but do not own its content: + // stamp the server-time boundary while keeping the block's eventId, which + // anchors replay ordering. + const stamp = event ? { ...event, eventId: undefined } : undefined; if (parentToolCallId) { - clearActiveAssistantForParent(state, parentToolCallId); - clearActiveThoughtForParent(state, parentToolCallId); + clearActiveAssistantForParent(state, parentToolCallId, stamp); + clearActiveThoughtForParent(state, parentToolCallId, stamp); } else { - finishAssistant(state); + finishAssistant(state, stamp); state.activeUserBlockId = undefined; } } diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 5472701372a..4bf616e3182 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -826,6 +826,8 @@ export interface DaemonTranscriptBlockBase { * display: clients viewing the same session see the same value. */ serverTimestamp?: number; + /** Daemon-authoritative wall clock for the latest event merged into this block. */ + serverUpdatedAt?: number; /** Ordered persisted ChatRecord identities that contributed to this block. */ sourceRecordIds?: readonly string[]; /** diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index ffa42b564fb..bfc7922df2b 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -3317,6 +3317,123 @@ describe('daemon UI time schema (PR-B)', () => { }); }); + it('preserves authoritative tool start and end times during replay', () => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 100_000 }), + [ + { + type: 'tool.update', + toolCallId: 'call-1', + status: 'in_progress', + serverTimestamp: 1_000, + }, + { + type: 'tool.update', + toolCallId: 'call-1', + status: 'completed', + serverTimestamp: 6_000, + }, + ], + { now: 100_000 }, + ); + + expect(state.blocks[0]).toMatchObject({ + kind: 'tool', + serverTimestamp: 1_000, + serverUpdatedAt: 6_000, + createdAt: 100_000, + updatedAt: 100_000, + }); + }); + + it('preserves authoritative thought start and end times during replay', () => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 100_000 }), + [ + { + type: 'thought.text.delta', + text: 'thinking', + serverTimestamp: 1_000, + }, + { + type: 'thought.text.delta', + text: ' more', + serverTimestamp: 3_000, + }, + { + type: 'assistant.text.delta', + text: 'answer', + serverTimestamp: 6_000, + }, + ], + { now: 100_000 }, + ); + + expect(state.blocks[0]).toMatchObject({ + kind: 'thought', + streaming: false, + serverTimestamp: 1_000, + serverUpdatedAt: 6_000, + }); + }); + + it('keeps the first delta stamp as serverTimestamp and the latest as serverUpdatedAt', () => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 100_000 }), + [ + { + type: 'thought.text.delta', + text: 'thinking', + serverTimestamp: 1_000, + }, + { + type: 'thought.text.delta', + text: ' more', + serverTimestamp: 3_000, + }, + ], + { now: 100_000 }, + ); + + expect(state.blocks[0]).toMatchObject({ + kind: 'thought', + serverTimestamp: 1_000, + serverUpdatedAt: 3_000, + }); + }); + + it('stamps serverUpdatedAt on a thought finalized by a tool update', () => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 100_000 }), + [ + { + type: 'thought.text.delta', + text: 'thinking', + serverTimestamp: 1_000, + }, + { + type: 'tool.update', + toolCallId: 'call-1', + status: 'in_progress', + serverTimestamp: 6_000, + }, + ], + { now: 100_000 }, + ); + + expect(state.blocks[0]).toMatchObject({ + kind: 'thought', + streaming: false, + serverTimestamp: 1_000, + serverUpdatedAt: 6_000, + }); + expect(state.blocks[1]).toMatchObject({ + kind: 'tool', + serverTimestamp: 6_000, + serverUpdatedAt: 6_000, + }); + }); + it('uses assistant.done timestamp when the active assistant block has none', () => { let state = createDaemonTranscriptState({ now: 1 }); state = reduceDaemonTranscriptEvents( @@ -3350,6 +3467,35 @@ describe('daemon UI time schema (PR-B)', () => { eventId: 2, serverTimestamp: 5_000, }); + expect(state.blocks[0]).not.toHaveProperty('serverUpdatedAt'); + }); + + it('does not create a server timing pair from a stamped thought end only', () => { + let state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 100_000 }), + [{ type: 'thought.text.delta', text: 'thinking' }], + { now: 100_000 }, + ); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'assistant.text.delta', + text: 'answer', + serverTimestamp: 6_000, + }, + ], + { now: 106_000 }, + ); + + expect(state.blocks[0]).toMatchObject({ + kind: 'thought', + createdAt: 100_000, + updatedAt: 106_000, + serverTimestamp: 6_000, + }); + expect(state.blocks[0]).not.toHaveProperty('serverUpdatedAt'); }); it('extracts serverTimestamp from top-level envelope field when present', () => { @@ -3627,6 +3773,33 @@ describe('daemon UI reducer state machine (PR-E)', () => { }); }); + it('stamps the cancelling event time on in-flight tools', () => { + let state = createDaemonTranscriptState({ now: 1 }); + state = reduceDaemonTranscriptEvents(state, [ + { + type: 'tool.update', + toolCallId: 'call-1', + status: 'running', + serverTimestamp: 1_000, + }, + ]); + + state = reduceDaemonTranscriptEvents(state, [ + { + type: 'assistant.done', + reason: 'cancelled', + serverTimestamp: 6_000, + }, + ]); + + expect(state.blocks[0]).toMatchObject({ + kind: 'tool', + status: 'cancelled', + serverTimestamp: 1_000, + serverUpdatedAt: 6_000, + }); + }); + it('enters resync-required state and skips later non-terminal deltas', () => { let state = createDaemonTranscriptState({ now: 1 }); state = reduceDaemonTranscriptEvents( diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index d941d480b38..3e2c57d0445 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -4613,6 +4613,33 @@ afterEach(() => { vi.unstubAllGlobals(); }); +describe('App compact mode', () => { + async function toggleCompactMode() { + await act(async () => { + window.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + ctrlKey: true, + key: 'o', + }), + ); + await Promise.resolve(); + }); + } + + it('uses Ctrl+O and persists the existing workspace setting', async () => { + renderApp(); + await toggleCompactMode(); + + expect(settingsSetValue).toHaveBeenCalledWith( + 'workspace', + 'ui.compactMode', + true, + ); + }); +}); + describe('App plan todos', () => { it('gates the exit-plan workflow on the experimental setting', async () => { const approvedEntries = [ diff --git a/packages/web-shell/client/adapters/messageTypes.ts b/packages/web-shell/client/adapters/messageTypes.ts index 5e807a927b2..9bfe8565c19 100644 --- a/packages/web-shell/client/adapters/messageTypes.ts +++ b/packages/web-shell/client/adapters/messageTypes.ts @@ -105,6 +105,8 @@ export interface DaemonThinkingMessage extends DaemonMessageMeta { role: 'thinking'; content: string; isStreaming?: boolean; + startTime?: number; + endTime?: number; } export interface DaemonToolGroupMessage extends DaemonMessageMeta { diff --git a/packages/web-shell/client/adapters/transcriptToMessages.test.ts b/packages/web-shell/client/adapters/transcriptToMessages.test.ts index 83ee4addb67..77113578207 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.test.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.test.ts @@ -118,6 +118,8 @@ function toolBlock( details: overrides.details, parentToolCallId: overrides.parentToolCallId, subagentType: overrides.subagentType, + serverTimestamp: overrides.serverTimestamp, + serverUpdatedAt: overrides.serverUpdatedAt, clientReceivedAt: createdAt, createdAt, updatedAt: overrides.updatedAt ?? createdAt, @@ -314,6 +316,121 @@ describe('transcriptBlocksToDaemonMessages', () => { }); }); + it('projects the daemon interval onto the client clock for replayed background agent notifications', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-block', 'agent-call', 'completed', 100_000, { + toolName: 'agent', + rawInput: { run_in_background: true }, + rawOutput: { type: 'task_execution', status: 'background' }, + serverTimestamp: 5_000, + }), + textBlock( + 'agent-terminal', + 'user', + 'background agent finished', + 200_000, + false, + { + serverTimestamp: 15_000, + meta: { + source: 'background_notification', + qwenDiscreteMessage: true, + backgroundTask: { + kind: 'agent', + status: 'completed', + taskId: 'agent-task', + toolUseId: 'agent-call', + }, + }, + }, + ), + ]); + + expect(messages[0]).toMatchObject({ + role: 'tool_group', + tools: [{ callId: 'agent-call', startTime: 190_000, endTime: 200_000 }], + }); + }); + + it('leaves timing untouched for a non-terminal background agent notification', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-block', 'agent-call', 'completed', 100_000, { + toolName: 'agent', + rawInput: { run_in_background: true }, + rawOutput: { type: 'task_execution', status: 'background' }, + serverTimestamp: 5_000, + serverUpdatedAt: 8_000, + }), + textBlock( + 'agent-progress', + 'assistant', + 'background agent progress', + 200_000, + false, + { + serverTimestamp: 15_000, + meta: { + source: 'background_notification', + qwenDiscreteMessage: true, + backgroundTask: { + kind: 'agent', + status: 'running', + taskId: 'agent-task', + toolUseId: 'agent-call', + }, + }, + }, + ), + ]); + + // In-flight projection keeps the client-projected start and no end time; + // the non-terminal notification must not overwrite either. + expect(messages[0]).toMatchObject({ + role: 'tool_group', + tools: [{ callId: 'agent-call', startTime: 97_000 }], + }); + if (messages[0]?.role === 'tool_group') { + expect(messages[0].tools[0]).not.toHaveProperty('endTime'); + } + }); + + it('falls back to client timing when a notification repeats the start stamp', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('agent-block', 'agent-call', 'completed', 100_000, { + toolName: 'agent', + rawInput: { run_in_background: true }, + rawOutput: { type: 'task_execution', status: 'background' }, + serverTimestamp: 5_000, + updatedAt: 150_000, + }), + textBlock( + 'agent-terminal', + 'user', + 'background agent finished', + 200_000, + false, + { + serverTimestamp: 5_000, + meta: { + source: 'background_notification', + qwenDiscreteMessage: true, + backgroundTask: { + kind: 'agent', + status: 'completed', + taskId: 'agent-task', + toolUseId: 'agent-call', + }, + }, + }, + ), + ]); + + expect(messages[0]).toMatchObject({ + role: 'tool_group', + tools: [{ callId: 'agent-call', startTime: 100_000, endTime: 200_000 }], + }); + }); + it('does not apply a non-agent background notification to an agent tool', () => { const messages = transcriptBlocksToDaemonMessages([ toolBlock('agent-block', 'agent-call', 'completed', 1, { @@ -2005,6 +2122,80 @@ describe('transcriptBlocksToDaemonMessages', () => { ]); }); + it('keeps merged permission placeholders on the tool block timing', () => { + const messages = transcriptBlocksToDaemonMessages([ + { + id: 'perm-1', + kind: 'permission', + requestId: 'req-1', + sessionId: 'sess-1', + title: 'Allow shell?', + options: [{ optionId: 'proceed_once', label: 'Allow', raw: {} }], + toolCall: { + toolCallId: 'tc-1', + kind: 'execute', + toolName: 'run_shell_command', + rawInput: { command: 'ls' }, + }, + preview: { kind: 'generic' as const }, + clientReceivedAt: 1_000, + createdAt: 1_000, + updatedAt: 2_000, + resolved: 'selected:proceed_once', + }, + toolBlock('tool-1', 'tc-1', 'completed', 3_000, { + toolName: 'run_shell_command', + updatedAt: 13_000, + serverTimestamp: 5_000, + serverUpdatedAt: 15_000, + }), + ]); + + expect(messages).toMatchObject([ + { + role: 'tool_group', + tools: [{ callId: 'tc-1', startTime: 3_000, endTime: 13_000 }], + }, + ]); + }); + + it('keeps the tool block timing when the tool block precedes the permission', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('tool-1', 'tc-1', 'completed', 3_000, { + toolName: 'run_shell_command', + updatedAt: 13_000, + serverTimestamp: 5_000, + serverUpdatedAt: 15_000, + }), + { + id: 'perm-1', + kind: 'permission', + requestId: 'req-1', + sessionId: 'sess-1', + title: 'Allow shell?', + options: [{ optionId: 'proceed_once', label: 'Allow', raw: {} }], + toolCall: { + toolCallId: 'tc-1', + kind: 'execute', + toolName: 'run_shell_command', + rawInput: { command: 'ls' }, + }, + preview: { kind: 'generic' as const }, + clientReceivedAt: 110_000, + createdAt: 110_000, + updatedAt: 111_000, + resolved: 'selected:proceed_once', + }, + ]); + + expect(messages).toMatchObject([ + { + role: 'tool_group', + tools: [{ callId: 'tc-1', startTime: 3_000, endTime: 13_000 }], + }, + ]); + }); + it('uses text content as raw output when a tool has no raw output', () => { const messages = transcriptBlocksToDaemonMessages([ toolBlock('ask-failed', 'ask-call-failed', 'failed', 1, { @@ -2893,6 +3084,138 @@ describe('transcriptBlocksToDaemonMessages', () => { content: 'let me think about this', isStreaming: false, timestamp: 1, + startTime: 1, + endTime: 1, + }, + ]); + }); + + it('preserves authoritative thinking duration across replay', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('t1', 'thought', 'thinking', 100_000, false, { + serverTimestamp: 1_000, + serverUpdatedAt: 6_000, + }), + ]); + + expect(messages[0]).toMatchObject({ + role: 'thinking', + startTime: 95_000, + endTime: 100_000, + }); + }); + + it('keeps the full timing range when adjacent thoughts merge', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('t1', 'thought', 'first', 100_000, false, { + serverTimestamp: 1_000, + serverUpdatedAt: 6_000, + }), + textBlock('t2', 'thought', ' second', 101_000, false, { + serverTimestamp: 6_000, + serverUpdatedAt: 9_000, + }), + ]); + + expect(messages).toMatchObject([ + { + role: 'thinking', + content: 'first second', + startTime: 95_000, + endTime: 103_000, + }, + ]); + }); + + it('projects live daemon timing onto the client clock', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('t1', 'thought', 'thinking', 100_000, true, { + updatedAt: 101_000, + serverTimestamp: 1_000, + serverUpdatedAt: 3_000, + }), + toolBlock('tool-1', 'call-1', 'in_progress', 200_000, { + updatedAt: 201_000, + serverTimestamp: 4_000, + serverUpdatedAt: 7_000, + }), + ]); + + expect(messages[0]).toMatchObject({ + role: 'thinking', + startTime: 99_000, + }); + expect(messages[0]).not.toHaveProperty('endTime'); + expect(messages[1]).toMatchObject({ + role: 'tool_group', + tools: [{ startTime: 198_000 }], + }); + if (messages[1]?.role === 'tool_group') { + expect(messages[1].tools[0]).not.toHaveProperty('endTime'); + } + }); + + it('preserves authoritative tool duration across replay', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('tool-1', 'call-1', 'completed', 100_000, { + serverTimestamp: 1_000, + serverUpdatedAt: 6_000, + }), + ]); + + expect(messages[0]).toMatchObject({ + role: 'tool_group', + tools: [{ startTime: 95_000, endTime: 100_000 }], + }); + }); + + it('does not mix server and client clocks for partial tool timestamps', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('tool-1', 'call-1', 'completed', 100_000, { + serverTimestamp: 1_000, + updatedAt: 106_000, + }), + ]); + + expect(messages[0]).toMatchObject({ + role: 'tool_group', + tools: [{ startTime: 100_000, endTime: 106_000 }], + }); + }); + + it('falls back to client timing for an identical thought server pair', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('t1', 'thought', 'thinking', 100_000, false, { + updatedAt: 106_000, + serverTimestamp: 6_000, + serverUpdatedAt: 6_000, + }), + ]); + + expect(messages[0]).toMatchObject({ + role: 'thinking', + startTime: 100_000, + endTime: 106_000, + }); + }); + + it('merges adjacent thinking blocks across timing provenance', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock('client', 'thought', 'first', 100_000, false, { + updatedAt: 101_000, + }), + textBlock('server', 'thought', 'second', 102_000, false, { + serverTimestamp: 1_000, + serverUpdatedAt: 6_000, + }), + ]); + + expect(messages).toMatchObject([ + { + role: 'thinking', + content: 'firstsecond', + startTime: 100_000, + endTime: 106_000, }, ]); }); @@ -2994,6 +3317,8 @@ describe('transcriptBlocksToDaemonMessages', () => { content: 'analyzing...', isStreaming: false, timestamp: 1, + startTime: 1, + endTime: 1, }, { id: 'a1', diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts index 3b885dbadeb..43d4826f0e7 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -54,7 +54,41 @@ interface TranscriptMessageOptions { interface BackgroundAgentTaskUpdate { status: string; - endTime: number; + serverEndTime?: number; + clientEndTime: number; +} + +function hasServerTimingPair( + block: DaemonTextTranscriptBlock | DaemonToolTranscriptBlock, +): boolean { + return ( + block.serverTimestamp !== undefined && + block.serverUpdatedAt !== undefined && + block.serverUpdatedAt > block.serverTimestamp + ); +} + +function getTranscriptTiming( + block: DaemonTextTranscriptBlock | DaemonToolTranscriptBlock, + complete: boolean, +): { startTime: number; endTime?: number } { + const serverStart = block.serverTimestamp; + const serverEnd = block.serverUpdatedAt; + const hasServerPair = hasServerTimingPair(block); + if (complete) { + if (!hasServerPair) { + return { startTime: block.createdAt, endTime: block.updatedAt }; + } + // Keep the daemon-measured interval but anchor it in the client clock so + // timestamps from different tools stay comparable (collectToolSpans sorts + // them; live durations subtract them from Date.now()). + const elapsed = Math.max(0, serverEnd! - serverStart!); + return { startTime: block.updatedAt - elapsed, endTime: block.updatedAt }; + } + const elapsed = hasServerPair + ? Math.max(0, serverEnd! - serverStart!) + : Math.max(0, block.updatedAt - block.createdAt); + return { startTime: block.updatedAt - elapsed }; } function collectBackgroundAgentTaskUpdates( @@ -76,7 +110,8 @@ function collectBackgroundAgentTaskUpdates( if (task?.['kind'] !== 'agent' || !toolUseId || !status) continue; updates.set(toolUseId, { status, - endTime: block.serverTimestamp ?? block.clientReceivedAt, + serverEndTime: block.serverTimestamp, + clientEndTime: block.clientReceivedAt, }); } return updates; @@ -85,27 +120,39 @@ function collectBackgroundAgentTaskUpdates( function applyBackgroundAgentTaskUpdate( tool: DaemonMessageToolCall, update: BackgroundAgentTaskUpdate | undefined, + block: DaemonToolTranscriptBlock, ): void { if (!update) return; switch (update.status) { case 'completed': tool.status = 'completed'; - tool.endTime = update.endTime; break; case 'failed': tool.status = 'failed'; - tool.endTime = update.endTime; break; case 'cancelled': case 'canceled': tool.status = 'completed'; - tool.endTime = update.endTime; tool.rawOutput = { ...(getRecord(tool.rawOutput) ?? {}), status: 'cancelled', }; break; + default: + // A non-terminal notification must not overwrite timing: that is the + // only way an in-flight tool could acquire an absolute daemon-clock + // start time. + return; } + const hasServerPair = + block.serverTimestamp !== undefined && + update.serverEndTime !== undefined && + update.serverEndTime > block.serverTimestamp; + const elapsed = hasServerPair + ? update.serverEndTime! - block.serverTimestamp! + : Math.max(0, update.clientEndTime - block.createdAt); + tool.startTime = update.clientEndTime - elapsed; + tool.endTime = update.clientEndTime; } function isIgnoredWebShellStatus(text: string): boolean { @@ -522,19 +569,34 @@ export function transcriptBlocksToDaemonMessages( ? messages[currentThinkingIdx] : undefined; if (target && target.role === 'thinking' && !needsNewContentMessage) { + const timing = getTranscriptTiming(textBlock, !textBlock.streaming); + // Accumulate each block's own duration instead of subtracting the + // merged endpoints, so blocks with different timing provenance + // (daemon pair vs client pair) keep an exact total. + const blockElapsed = + timing.endTime !== undefined + ? timing.endTime - timing.startTime + : undefined; + const prevEnd = target.endTime ?? target.startTime; messages[currentThinkingIdx!] = { ...target, content: target.content + textBlock.text, isStreaming: textBlock.streaming, + endTime: + blockElapsed !== undefined && prevEnd !== undefined + ? prevEnd + blockElapsed + : timing.endTime, }; needsNewContentMessage = false; } else { + const timing = getTranscriptTiming(textBlock, !textBlock.streaming); messages.push({ id: block.id, role: 'thinking', content: textBlock.text, isStreaming: textBlock.streaming, timestamp: blockTime, + ...timing, }); currentThinkingIdx = messages.length - 1; needsNewContentMessage = false; @@ -549,6 +611,7 @@ export function transcriptBlocksToDaemonMessages( applyBackgroundAgentTaskUpdate( toolCall, backgroundAgentTaskUpdates.get(toolCall.callId), + toolBlock, ); const permissionInfo = permissionToolInfoByCallId.get(toolCall.callId); if (permissionInfo?.title) { @@ -668,6 +731,9 @@ export function transcriptBlocksToDaemonMessages( permissionToolCall.endTime = permBlock.updatedAt; } } + // The existing tool carries its own timing pair (server clock when + // available); the placeholder's client-clock createdAt would corrupt it. + permissionToolCall.startTime = undefined; mergeToolCall(existingPermission, permissionToolCall); if ( isTerminalToolStatus(previousStatus) || @@ -895,6 +961,7 @@ function mergeToolCall( target.toolName = source.toolName ?? target.toolName; target.kind = source.kind ?? target.kind; target.content = source.content ?? target.content; + target.startTime = source.startTime ?? target.startTime; target.endTime = source.endTime ?? target.endTime; target.rawOutput = source.rawOutput ?? target.rawOutput; target.args = source.args ?? target.args; @@ -997,6 +1064,7 @@ function daemonToolBlockToToolCall( block.status === 'failed' || block.status === 'cancelled' || block.status === 'canceled'; + const timing = getTranscriptTiming(block, isComplete && !isBackgroundAgent); return { callId: block.toolCallId, @@ -1010,8 +1078,7 @@ function daemonToolBlockToToolCall( rawOutput, args: block.rawInput as Record | undefined, parentToolCallId: block.parentToolCallId, - startTime: block.createdAt, - endTime: isComplete && !isBackgroundAgent ? block.updatedAt : undefined, + ...timing, ...(content ? { content } : {}), }; } diff --git a/packages/web-shell/client/components/MessageItem.dom.test.tsx b/packages/web-shell/client/components/MessageItem.dom.test.tsx index b93dc23ba8a..7967f500ec2 100644 --- a/packages/web-shell/client/components/MessageItem.dom.test.tsx +++ b/packages/web-shell/client/components/MessageItem.dom.test.tsx @@ -10,6 +10,11 @@ import { } from '../customization'; import type { Message } from '../adapters/types'; +vi.mock('../App', async () => { + const { createContext } = await import('react'); + return { CompactModeContext: createContext(false) }; +}); + // Stub the message body components so MessageItem's own wiring — not the bodies // — is under test. UserMessage/AssistantMessage throw on a sentinel so we can // drive the message-level ErrorBoundary (the real one, imported below); the @@ -18,8 +23,18 @@ import type { Message } from '../adapters/types'; vi.mock('./MessageTimestamp', async () => { const React = await import('react'); return { - MessageTimestamp: ({ children }: { children: React.ReactNode }) => - React.createElement('div', null, children), + MessageTimestamp: ({ + children, + toolGroupSpacing, + }: { + children: React.ReactNode; + toolGroupSpacing?: boolean; + }) => + React.createElement( + 'div', + { 'data-tool-group-spacing': String(toolGroupSpacing === true) }, + children, + ), formatTimestamp: () => '', }; }); @@ -55,10 +70,20 @@ vi.mock('./messages/AssistantMessage', async () => { customFooter, ); }, - ThinkingMessage: ({ generateContent }: { generateContent?: unknown }) => + ThinkingMessage: ({ + generateContent, + startTime, + endTime, + }: { + generateContent?: unknown; + startTime?: number; + endTime?: number; + }) => React.createElement('div', { 'data-testid': 'thinking', 'data-has-generator': generateContent !== undefined ? 'true' : 'false', + 'data-start-time': startTime, + 'data-end-time': endTime, }), }; }); @@ -73,6 +98,7 @@ vi.mock('./InsightProgress', () => ({ InsightProgress: () => null })); vi.mock('./InsightReady', () => ({ InsightReady: () => null })); const { MessageItem } = await import('./MessageItem'); +const { CompactModeContext } = await import('../App'); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -112,6 +138,12 @@ const assistantMsg = (id: string, content: string): Message => ({ id, role: 'assistant', content, timestamp: 0 }) as Message; const thinkingMsg = (id: string, content: string): Message => ({ id, role: 'thinking', content, timestamp: 0 }) as Message; +const toolMsg = (id: string): Message => ({ + id, + role: 'tool_group', + tools: [], + timestamp: 0, +}); function item(message: Message) { return ; @@ -195,7 +227,74 @@ describe('MessageItem selectable wrapper', () => { }); }); +describe('MessageItem tool group spacing', () => { + it('uses larger row spacing only in compact mode', () => { + const compact = render( + + + {item(toolMsg('compact'))} + + , + ); + const regular = render( + + + {item(toolMsg('regular'))} + + , + ); + const compactAssistant = render( + + + {item(assistantMsg('assistant', 'answer'))} + + , + ); + const defaultTool = render( + {item(toolMsg('default'))}, + ); + + expect( + compact.firstElementChild?.getAttribute('data-tool-group-spacing'), + ).toBe('true'); + expect( + regular.firstElementChild?.getAttribute('data-tool-group-spacing'), + ).toBe('false'); + expect( + compactAssistant.firstElementChild?.getAttribute( + 'data-tool-group-spacing', + ), + ).toBe('false'); + expect( + defaultTool.firstElementChild?.getAttribute('data-tool-group-spacing'), + ).toBe('false'); + }); +}); + describe('MessageItem generation updates', () => { + it('rerenders a thinking message when its timing changes', () => { + const message = thinkingMsg('1', 'reasoning'); + const { root, container } = renderWithRoot( + + + , + ); + + act(() => + root.render( + + + , + ), + ); + + const thinking = container.querySelector('[data-testid="thinking"]'); + expect(thinking?.getAttribute('data-start-time')).toBe('1000'); + expect(thinking?.getAttribute('data-end-time')).toBe('6000'); + }); + it('rerenders a thinking message when generation becomes available', () => { const message = thinkingMsg('1', 'reasoning'); const { root, container } = renderWithRoot( diff --git a/packages/web-shell/client/components/MessageItem.tsx b/packages/web-shell/client/components/MessageItem.tsx index 893c35663e9..3e3a26083a4 100644 --- a/packages/web-shell/client/components/MessageItem.tsx +++ b/packages/web-shell/client/components/MessageItem.tsx @@ -1,10 +1,11 @@ -import { memo, type ReactElement } from 'react'; +import { memo, useContext, type ReactElement } from 'react'; import type { ACPToolCall, Message, PermissionRequest, TodoItem, } from '../adapters/types'; +import { CompactModeContext } from '../App'; import type { WebShellAssistantTurnFooterRenderInfo } from '../customization'; import { useI18n } from '../i18n'; import { ErrorBoundary } from './ErrorBoundary'; @@ -63,6 +64,7 @@ export const MessageItem = memo(function MessageItem({ generateContent, }: MessageItemProps) { const { t } = useI18n(); + const compactMode = useContext(CompactModeContext); const body = ((): ReactElement | null => { switch (message.role) { case 'user': @@ -97,6 +99,8 @@ export const MessageItem = memo(function MessageItem({ content={message.content} isStreaming={message.isStreaming} timestamp={message.timestamp} + startTime={message.startTime} + endTime={message.endTime} isLocateFlashing={isLocateFlashing} generateContent={generateContent} /> @@ -227,6 +231,7 @@ export const MessageItem = memo(function MessageItem({ @@ -327,7 +332,9 @@ function areMessagesEqual(prev: Message, next: Message): boolean { return ( next.role === 'thinking' && prev.content === next.content && - prev.isStreaming === next.isStreaming + prev.isStreaming === next.isStreaming && + prev.startTime === next.startTime && + prev.endTime === next.endTime ); case 'system': return ( diff --git a/packages/web-shell/client/components/MessageList.dom.test.tsx b/packages/web-shell/client/components/MessageList.dom.test.tsx index 4756475e1bc..169ca165f8c 100644 --- a/packages/web-shell/client/components/MessageList.dom.test.tsx +++ b/packages/web-shell/client/components/MessageList.dom.test.tsx @@ -59,6 +59,11 @@ vi.mock('./MessageItem', async () => { 'data-assistant-actions': String(Boolean(showAssistantActions)), 'data-locate-flashing': isLocateFlashing ? 'true' : undefined, 'data-send-failed': sendFailed ? 'true' : undefined, + 'data-timestamp': message.timestamp, + 'data-tool-ids': + message.role === 'tool_group' + ? message.tools.map((tool) => tool.callId).join(',') + : undefined, }, sendFailed ? React.createElement( @@ -114,6 +119,7 @@ vi.mock('@tanstack/react-virtual', () => ({ })); const { MessageList } = await import('./MessageList'); +const { CompactModeContext } = await import('../App'); type MessageListHandle = import('./MessageList').MessageListHandle; ( @@ -204,6 +210,11 @@ const agentMsg = (id: string): ToolGroupMessage => ({ }, ], }); +const standaloneToolMsg = (id: string, toolName: string): ToolGroupMessage => ({ + id, + role: 'tool_group', + tools: [{ callId: `call-${id}`, toolName, status: 'completed' }], +}); const asstMsg = (id: string): AssistantMessage => ({ id, role: 'assistant', @@ -294,6 +305,7 @@ function mount( includeSubagentToolUsageInMetrics?: boolean; onCanScrollToBottomChange?: (canScrollToBottom: boolean) => void; customization?: WebShellCustomization; + compactMode?: boolean; failedPromptMessageId?: string; onRetryFailedPrompt?: () => void; } = {}, @@ -305,35 +317,37 @@ function mount( root.render( - - - + + + + + , ); @@ -361,15 +375,17 @@ function rerenderMessages( entry.root.render( - - - + + + + + , ); @@ -487,6 +503,122 @@ describe('MessageList — failed prompt retry', () => { }); }); +describe('MessageList — compact mode', () => { + it('hides thinking rows without removing surrounding transcript content', () => { + const container = mount( + [userMsg('u1'), thinkingMsg('t1'), asstMsg('a1')], + undefined, + { + compactMode: true, + customization: { collapseCompletedTurns: false }, + }, + ); + + expect(container.querySelector('[data-testid="msg-u1"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="msg-t1"]')).toBeNull(); + expect(container.querySelector('[data-testid="msg-a1"]')).not.toBeNull(); + }); + + it('merges tool groups separated only by hidden thinking', () => { + const container = mount( + [ + userMsg('u1'), + { ...toolMsg('g1'), timestamp: 1_000 }, + thinkingMsg('t1'), + { ...toolMsg('g2'), timestamp: 2_000 }, + asstMsg('a1'), + userMsg('u2'), + toolMsg('g3'), + ], + undefined, + { + compactMode: true, + customization: { collapseCompletedTurns: false }, + }, + ); + + expect(container.querySelector('[data-testid="msg-g1"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="msg-g2"]')).toBeNull(); + expect( + container + .querySelector('[data-testid="msg-g1"]') + ?.getAttribute('data-timestamp'), + ).toBe('1000'); + expect( + container + .querySelector('[data-testid="msg-g1"]') + ?.getAttribute('data-tool-ids'), + ).toBe('call-g1,call-g2'); + expect(container.querySelector('[data-testid="msg-a1"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="msg-g3"]')).not.toBeNull(); + }); + + it('keeps visible thinking and tool groups in transcript order', () => { + const container = mount( + [ + userMsg('u1'), + toolMsg('g1'), + thinkingMsg('t1'), + toolMsg('g2'), + asstMsg('a1'), + ], + undefined, + { customization: { collapseCompletedTurns: false } }, + ); + + expect(container.querySelector('[data-testid="msg-t1"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="msg-g1"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="msg-g2"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="msg-a1"]')).not.toBeNull(); + expect( + Array.from(container.querySelectorAll('[data-testid^="msg-"]')).map( + (element) => element.getAttribute('data-testid'), + ), + ).toEqual(['msg-u1', 'msg-g1', 'msg-t1', 'msg-g2', 'msg-a1']); + }); + + it('keeps agent groups on their parallel-agent path', () => { + const container = mount( + [ + userMsg('u1'), + agentMsg('agent-1'), + thinkingMsg('t1'), + agentMsg('agent-2'), + ], + undefined, + { + compactMode: true, + customization: { collapseCompletedTurns: false }, + }, + ); + + expect(parallelAgentsSummary(container)).not.toBeNull(); + }); + + it.each(['TodoWrite', 'AskUserQuestion'])( + 'keeps %s groups separate across hidden thinking', + (toolName) => { + const container = mount( + [ + toolMsg('g1'), + thinkingMsg('t1'), + standaloneToolMsg('special', toolName), + ], + undefined, + { + compactMode: true, + customization: { collapseCompletedTurns: false }, + }, + ); + + expect(container.querySelector('[data-testid="msg-g1"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="msg-special"]'), + ).not.toBeNull(); + }, + ); +}); + describe('MessageList — turn collapse (DOM)', () => { it('reloads an oversized transcript after 120 quiet seconds at the tail', async () => { vi.useFakeTimers(); diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index 5fb6519ef23..6d99972aedb 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -46,9 +46,11 @@ import { import { ParallelAgentsGroup } from './messages/tools/ParallelAgentsGroup'; import { useSharedNow } from '../hooks/useSharedNow'; import { + isAskUserQuestionToolName, isActiveToolStatus, toolContainsCallId, } from './messages/toolFormatting'; +import { isTodoWriteToolName } from '../utils/todos'; import turnCollapseStyles from './TurnCollapseRow.module.css'; import flashStyles from './MessageLocateFlash.module.css'; import styles from './MessageList.module.css'; @@ -262,8 +264,19 @@ function isForceExpandGroup( } function isHiddenInCompactMode(msg: Message): boolean { - if (msg.role === 'thinking') return true; - return false; + return msg.role === 'thinking'; +} + +function isStandaloneToolGroup(msg: Message): boolean { + return ( + msg.role === 'tool_group' && + msg.tools.some( + (tool) => + isSubAgentToolCall(tool) || + isTodoWriteToolName(tool.toolName) || + isAskUserQuestionToolName(tool.toolName), + ) + ); } function mergeCompactToolGroups( @@ -276,7 +289,11 @@ function mergeCompactToolGroups( while (i < messages.length) { const msg = messages[i]; - if (msg.role !== 'tool_group' || isForceExpandGroup(msg, pendingApproval)) { + if ( + msg.role !== 'tool_group' || + isForceExpandGroup(msg, pendingApproval) || + isStandaloneToolGroup(msg) + ) { if (!isHiddenInCompactMode(msg)) { result.push(msg); } @@ -298,7 +315,8 @@ function mergeCompactToolGroups( if ( next.role === 'tool_group' && - !isForceExpandGroup(next, pendingApproval) + !isForceExpandGroup(next, pendingApproval) && + !isStandaloneToolGroup(next) ) { mergeableGroups.push(next); lastMergedIdx = j; @@ -322,6 +340,7 @@ function mergeCompactToolGroups( id: mergeableGroups[0].id, role: 'tool_group', tools: mergedTools, + timestamp: mergeableGroups[0].timestamp, }); i = lastMergedIdx + 1; } @@ -2487,6 +2506,7 @@ export const MessageList = memo( const { t } = useI18n(); const transcriptRenderMode = useTranscriptRenderMode(); const compactMode = useContext(CompactModeContext); + const { collapseCompletedTurns } = useWebShellCustomization(); const mergedMessages = useMemo( () => compactMode @@ -2727,7 +2747,6 @@ export const MessageList = memo( // (collapsed once complete). `displayItems` stays the full, pre-collapse // list — used only to locate rows hidden inside a collapsed turn — while // `visibleItems` is what actually renders. - const { collapseCompletedTurns } = useWebShellCustomization(); const collapseEnabled = collapseCompletedTurns ?? true; const [collapseOverrides, setCollapseOverrides] = useState< ReadonlyMap diff --git a/packages/web-shell/client/components/MessageTimestamp.module.css b/packages/web-shell/client/components/MessageTimestamp.module.css index b13fec52e0f..36a13f8265d 100644 --- a/packages/web-shell/client/components/MessageTimestamp.module.css +++ b/packages/web-shell/client/components/MessageTimestamp.module.css @@ -3,6 +3,10 @@ padding: 5px 0; } +.toolGroupSpacing { + padding: 8px 0; +} + /* * Anchored inside the message's top-right corner rather than floating above * it: the message list (`.list`) is `overflow-y: auto`, so a tooltip spilling diff --git a/packages/web-shell/client/components/MessageTimestamp.test.tsx b/packages/web-shell/client/components/MessageTimestamp.test.tsx index c179812314f..318b393693a 100644 --- a/packages/web-shell/client/components/MessageTimestamp.test.tsx +++ b/packages/web-shell/client/components/MessageTimestamp.test.tsx @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { act, type ReactNode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { MessageTimestamp, formatTimestamp } from './MessageTimestamp'; +import styles from './MessageTimestamp.module.css'; ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -65,7 +66,7 @@ describe('MessageTimestamp', () => { expect(container.textContent).toContain('body'); }); - it('renders children unchanged with no tooltip when timestamp is undefined', () => { + it('keeps row spacing with no tooltip when timestamp is undefined', () => { const container = render(
body
@@ -73,10 +74,28 @@ describe('MessageTimestamp', () => { ); expect(container.querySelector('span[aria-hidden="true"]')).toBeNull(); - // No wrapper element is introduced: the child stays a direct child of the - // mount container, so message spacing/structure is untouched. const child = container.querySelector('[data-testid="child"]'); expect(child).not.toBeNull(); - expect(child?.parentElement).toBe(container); + expect(child?.parentElement?.classList.contains(styles.row)).toBe(true); + }); + + it('uses larger spacing only when requested for a tool group', () => { + const defaultRow = render( + +
default
+
, + ); + const toolRow = render( + +
tool
+
, + ); + + expect(defaultRow.firstElementChild?.classList).not.toContain( + styles.toolGroupSpacing, + ); + expect(toolRow.firstElementChild?.classList).toContain( + styles.toolGroupSpacing, + ); }); }); diff --git a/packages/web-shell/client/components/MessageTimestamp.tsx b/packages/web-shell/client/components/MessageTimestamp.tsx index ebd4ffb260a..2c37c03156c 100644 --- a/packages/web-shell/client/components/MessageTimestamp.tsx +++ b/packages/web-shell/client/components/MessageTimestamp.tsx @@ -7,19 +7,22 @@ interface MessageTimestampProps { children: ReactNode; /** When true, show the timestamp permanently at bottom-right instead of hover tooltip. */ chatMode?: boolean; + /** Use the larger vertical rhythm for tool summaries in thinking-hidden mode. */ + toolGroupSpacing?: boolean; copyText?: string; copyTitle?: string; } /** * Wraps a rendered history message and reveals its wall-clock time as a - * CSS-only tooltip on hover. When the message carries no timestamp the - * children render unchanged, so no empty wrapper is introduced. + * CSS-only tooltip on hover. The row wrapper is always rendered so message + * spacing does not depend on timestamp availability. */ export function MessageTimestamp({ timestamp, children, chatMode = false, + toolGroupSpacing = false, copyText, copyTitle = 'Copy', }: MessageTimestampProps) { @@ -34,9 +37,6 @@ export function MessageTimestamp({ }) .catch(() => {}); }, [copyText]); - if (timestamp === undefined && !copyText) { - return <>{children}; - } const copyButton = copyText ? ( ) : null; + const rowClassName = chatMode + ? styles.chatRow + : toolGroupSpacing + ? `${styles.row} ${styles.toolGroupSpacing}` + : styles.row; if (timestamp === undefined) { return ( -
+
{children} {copyButton}
); } return ( -
+
{children} {chatMode ? ( diff --git a/packages/web-shell/client/components/dialogs/HelpDialog.test.tsx b/packages/web-shell/client/components/dialogs/HelpDialog.test.tsx new file mode 100644 index 00000000000..fc79c0614f1 --- /dev/null +++ b/packages/web-shell/client/components/dialogs/HelpDialog.test.tsx @@ -0,0 +1,36 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it } from 'vitest'; +import { I18nProvider, type WebShellLanguage } from '../../i18n'; +import { HelpDialog } from './HelpDialog'; + +const containers: HTMLDivElement[] = []; + +afterEach(() => { + for (const container of containers.splice(0)) container.remove(); +}); + +describe('HelpDialog shortcuts', () => { + it.each([ + ['en', 'Toggle compact mode'], + ['zh-CN', '切换紧凑模式'], + ] as const)('documents Ctrl+O in %s', (language, description) => { + const container = document.createElement('div'); + containers.push(container); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + + , + ); + }); + + expect(container.textContent).toContain('Ctrl+O'); + expect(container.textContent).toContain(description); + act(() => root.unmount()); + }); +}); diff --git a/packages/web-shell/client/components/dialogs/HelpDialog.tsx b/packages/web-shell/client/components/dialogs/HelpDialog.tsx index a4a828fd2e6..cd4453e03c2 100644 --- a/packages/web-shell/client/components/dialogs/HelpDialog.tsx +++ b/packages/web-shell/client/components/dialogs/HelpDialog.tsx @@ -84,6 +84,7 @@ const GENERAL_SHORTCUTS: Array<[string, string]> = [ ['Esc', 'help.shortcut.cancel'], ['Ctrl+J', 'help.shortcut.newline'], ['Ctrl+L', 'help.shortcut.clear'], + ['Ctrl+O', 'help.shortcut.compact'], ['Ctrl+Y', 'help.shortcut.retry'], ['Shift+Tab', 'help.shortcut.approvals'], ['Alt+Left/Right', 'help.shortcut.altWords'], diff --git a/packages/web-shell/client/components/messages/AssistantMessage.test.tsx b/packages/web-shell/client/components/messages/AssistantMessage.test.tsx index d9e1c5d2514..3f9bb7311ca 100644 --- a/packages/web-shell/client/components/messages/AssistantMessage.test.tsx +++ b/packages/web-shell/client/components/messages/AssistantMessage.test.tsx @@ -93,19 +93,60 @@ describe('AssistantMessage thinking logic', () => { expect(formatThinkingDuration(120_000)).toBe('2m'); }); - it('keeps replayed completed thinking durationless', () => { + it('keeps replayed completed thinking duration stable', () => { + vi.setSystemTime(100_000); const container = render( , ); + expect(container.textContent).toContain('Thought for 5s'); + }); + + it('keeps completed thinking without timing durationless', () => { + const container = render( + , + ); + expect(container.textContent).toContain('Done thinking'); expect(container.textContent).not.toContain('Thought for'); }); + it('uses authoritative timing when a live thought completes', () => { + vi.setSystemTime(100_000); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + const tree = (props: { + isStreaming: boolean; + startTime: number; + endTime?: number; + }) => ( + + + + ); + + act(() => root.render(tree({ isStreaming: true, startTime: 99_000 }))); + expect(container.textContent).toContain('Thinking 1s'); + + act(() => + root.render( + tree({ isStreaming: false, startTime: 1_000, endTime: 6_000 }), + ), + ); + expect(container.textContent).toContain('Thought for 5s'); + }); + it.each([ [999, 'Thought briefly'], [1000, 'Thought for 1s'], diff --git a/packages/web-shell/client/components/messages/AssistantMessage.tsx b/packages/web-shell/client/components/messages/AssistantMessage.tsx index 2f9c0f6c5f7..33aff2251a4 100644 --- a/packages/web-shell/client/components/messages/AssistantMessage.tsx +++ b/packages/web-shell/client/components/messages/AssistantMessage.tsx @@ -189,6 +189,8 @@ interface ThinkingMessageProps { content: string; isStreaming?: boolean; timestamp?: number; + startTime?: number; + endTime?: number; isLocateFlashing?: boolean; generateContent?: SessionContentGenerator; } @@ -225,6 +227,8 @@ export const ThinkingMessage = memo(function ThinkingMessage({ content, isStreaming, timestamp, + startTime, + endTime, isLocateFlashing = false, generateContent, }: ThinkingMessageProps) { @@ -232,10 +236,10 @@ export const ThinkingMessage = memo(function ThinkingMessage({ const compactMode = useContext(CompactModeContext); const [thinkingExpanded, setThinkingExpanded] = useState(false); const thinkingActive = isStreaming === true; - const startTimeRef = useRef(timestamp ?? Date.now()); + const startTimeRef = useRef(startTime ?? timestamp ?? Date.now()); const sawActiveRef = useRef(thinkingActive); const [now, setNow] = useState(() => Date.now()); - const [finishedAt, setFinishedAt] = useState(null); + const [finishedAt, setFinishedAt] = useState(endTime ?? null); const [translationOpen, setTranslationOpen] = useState(false); const [translation, setTranslation] = useState(); const [translationLoading, setTranslationLoading] = useState(false); @@ -251,7 +255,7 @@ export const ThinkingMessage = memo(function ThinkingMessage({ }, [content, thinkingActive]); useEffect(() => { - if (!content) return; + if (!content || endTime !== undefined) return; if (thinkingActive) { sawActiveRef.current = true; setFinishedAt(null); @@ -260,11 +264,13 @@ export const ThinkingMessage = memo(function ThinkingMessage({ if (sawActiveRef.current && finishedAt === null) { setFinishedAt(Date.now()); } - }, [content, finishedAt, thinkingActive]); + }, [content, endTime, finishedAt, thinkingActive]); + const effectiveFinishedAt = endTime ?? finishedAt; const thinkingDurationMs = - thinkingActive || finishedAt !== null - ? (thinkingActive ? now : finishedAt!) - startTimeRef.current + thinkingActive || effectiveFinishedAt !== null + ? (thinkingActive ? now : effectiveFinishedAt!) - + (startTime ?? startTimeRef.current) : undefined; const thinkingSummaryKey = getThinkingSummaryKey({ isStreaming, diff --git a/packages/web-shell/client/components/messages/ToolGroup.test.tsx b/packages/web-shell/client/components/messages/ToolGroup.test.tsx index d8fae55f4ac..3c810f089d1 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.test.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.test.tsx @@ -13,7 +13,6 @@ import { MonitorDetailsProvider } from '../../monitorDetailsContext'; vi.mock('../../App', async () => { const { createContext } = await import('react'); return { - CompactModeContext: createContext(false), TodoTimelineContext: createContext(new Map()), TodoDetailContext: createContext(new Map()), }; @@ -186,6 +185,78 @@ describe('tool group summary logic', () => { expect(formatToolGroupSummary(tools, t)).toBe('Running ReadFile · 2 tools'); }); + it('describes every active foreground tool until all tools finish', () => { + const tools = [ + makeTool({ + callId: 'read', + toolName: 'ReadFile', + status: 'in_progress', + args: { file_path: 'package.json' }, + }), + makeTool({ + callId: 'search', + toolName: 'grep', + status: 'pending', + args: { pattern: 'ToolGroup' }, + }), + makeTool({ callId: 'done', status: 'completed' }), + ]; + + const summary = formatToolGroupSummary(tools, t); + expect(summary).toContain('ReadFile package.json'); + expect(summary).toContain('ToolGroup'); + expect(summary).toContain('3 tools'); + }); + + it('excludes a running background agent from a multi-tool summary', () => { + const tools = [ + makeTool({ + callId: 'agent', + toolName: 'agent', + status: 'in_progress', + args: { run_in_background: true }, + }), + makeTool({ + callId: 'read', + toolName: 'ReadFile', + status: 'in_progress', + args: { file_path: 'package.json' }, + }), + makeTool({ + callId: 'search', + toolName: 'grep', + status: 'pending', + args: { pattern: 'ToolGroup' }, + }), + ]; + + const summary = formatToolGroupSummary(tools, t); + expect(summary).toBe( + "Running ReadFile package.json · Grep 'ToolGroup' in path './' · 3 tools", + ); + }); + + it('renders workspace-relative paths in multi-tool running summaries', () => { + const tools = [ + makeTool({ + callId: 'read', + toolName: 'ReadFile', + status: 'in_progress', + args: { file_path: '/workspace/project/src/foo.ts' }, + }), + makeTool({ callId: 'done', status: 'completed' }), + ]; + + const summary = formatToolGroupSummary( + tools, + t, + undefined, + '/workspace/project', + ); + expect(summary).toContain('ReadFile src/foo.ts'); + expect(summary).not.toContain('/workspace/project/src/foo.ts'); + }); + it('localizes active tool names in running summaries', () => { const tools = [ makeTool({ @@ -537,6 +608,90 @@ describe('tool kind logic', () => { }); describe('tool row rendering', () => { + it('renders the aggregate summary for a multi-tool group', () => { + const container = renderToolGroup([ + makeTool({ + callId: 'read', + toolName: 'ReadFile', + status: 'in_progress', + args: { file_path: 'package.json' }, + }), + makeTool({ + callId: 'search', + toolName: 'grep', + status: 'pending', + args: { pattern: 'ToolGroup' }, + }), + ]); + + expect(container.querySelector('button')?.textContent).toContain( + 'package.json', + ); + expect(container.querySelector('button')?.textContent).toContain( + 'ToolGroup', + ); + }); + + it('continues a running summary timer from the persisted tool start', () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(6_000); + const container = renderToolGroup([ + makeTool({ status: 'in_progress', startTime: 1_000 }), + makeTool({ callId: 'done', status: 'completed' }), + ]); + + try { + expect(container.querySelector('button')?.textContent).toContain('5s'); + } finally { + now.mockRestore(); + } + }); + + it('falls back to a local timer when the running tool has no start time', () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(6_000); + const container = renderToolGroup([ + makeTool({ status: 'in_progress' }), + makeTool({ callId: 'done', status: 'completed' }), + ]); + + try { + expect(container.querySelector('button')?.textContent).toContain('1s'); + } finally { + now.mockRestore(); + } + }); + + it('shows stable elapsed time from persisted tool timestamps', () => { + const container = renderToolLine( + makeTool({ + toolName: 'ReadFile', + status: 'completed', + startTime: 1_000, + endTime: 6_000, + }), + ); + + expect(container.textContent).toContain('5s'); + }); + + it('shows a tool-kind icon on every expanded group row', () => { + const container = renderToolGroup([ + makeTool({ callId: 'read', toolName: 'ReadFile' }), + makeTool({ callId: 'edit', toolName: 'edit' }), + ]); + const summary = container.querySelector('button'); + act(() => summary?.click()); + + const rows = container.querySelectorAll( + '[class*="chatSummaryGroup"] [class*="lineMain"]', + ); + expect(rows).toHaveLength(2); + for (const row of rows) { + expect( + row.querySelector('svg[class*="chatSummaryToolIcon"]'), + ).not.toBeNull(); + } + }); + it('shows failed status in the collapsed chat summary', () => { const container = renderToolGroup([ makeTool({ toolName: 'Shell', status: 'failed' }), diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index 1e2f69c191e..cadcb9d54b5 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -61,7 +61,7 @@ import { } from './toolFormatting'; import { useI18n } from '../../i18n'; import { useTranscriptRenderMode } from '../../transcriptRenderMode'; -import { CompactModeContext, TodoTimelineContext } from '../../App'; +import { TodoTimelineContext } from '../../App'; import { type ToolHeaderExtraRenderInfo, type ToolHeaderKind, @@ -591,21 +591,29 @@ export function formatToolGroupSummary( tools: ACPToolCall[], t: ReturnType['t'], duration?: string, + workspaceCwd?: string, ): string { if (hasActiveAgents(tools)) { - const foregroundActiveTool = tools.find( + const foregroundActiveTools = tools.filter( (tool) => isActiveToolStatus(tool.status) && !isBackgroundSubAgentToolCall(tool), ); - const activeTool = foregroundActiveTool ?? getActiveTool(tools); - if (isAskUserQuestionToolName(activeTool.toolName)) { - return t('toolGroup.summary.provideInformation'); - } - if (!foregroundActiveTool && isBackgroundSubAgentToolCall(activeTool)) { + if (foregroundActiveTools.length === 0) { return t('subagent.background'); } + if ( + foregroundActiveTools.length === 1 && + isAskUserQuestionToolName(foregroundActiveTools[0].toolName) + ) { + return t('toolGroup.summary.provideInformation'); + } + const activeSummaries = foregroundActiveTools.map((tool) => + isAskUserQuestionToolName(tool.toolName) + ? t('toolGroup.summary.provideInformation') + : formatSingleToolSummary(tool, t, workspaceCwd), + ); return t('toolGroup.running', { - name: localizeToolDisplayName(activeTool.toolName, t), + name: activeSummaries.join(' · '), count: tools.length, duration: duration ?? '', }); @@ -959,61 +967,6 @@ export function isWebFetchToolName(toolName: string): boolean { return name === 'web_fetch' || name === 'webfetch' || name === 'fetch'; } -const getCompactDisplayStatus = getAgentDisplayStatus; - -function CompactToolGroup({ - tools, - workspaceCwd, - isLocateFlashing = false, -}: { - tools: ACPToolCall[]; - workspaceCwd?: string; - isLocateFlashing?: boolean; -}) { - const { t } = useI18n(); - const activeTool = getActiveTool(tools); - const displayName = localizeToolDisplayName(activeTool.toolName, t); - const overallStatus = getCompactDisplayStatus(activeTool); - const description = getToolDescription(activeTool, workspaceCwd); - const elapsed = - (isActiveToolStatus(activeTool.status) && - isBackgroundSubAgentToolCall(activeTool)) || - isShellToolName(activeTool.toolName) || - isWebFetchToolName(activeTool.toolName) - ? '' - : formatElapsed(activeTool.startTime, activeTool.endTime); - - return ( -
-
- - {displayName} - {tools.length > 1 && ( - - {'× '} - {tools.length} - - )} - -
-
{t('compact.hint')}
-
- ); -} - function areToolLinePropsEqual( prev: ToolLineProps, next: ToolLineProps, @@ -1128,14 +1081,13 @@ export const ToolLine = memo(function ToolLine({ }: ToolLineProps) { const { t } = useI18n(); const transcriptRenderMode = useTranscriptRenderMode(); - const compactMode = useContext(CompactModeContext); const subagentDetails = useSubagentDetails(); const monitorDetails = useMonitorDetails(); const monitorDetailsAvailable = monitorDetails !== undefined; const [monitorDetailsUnavailable, setMonitorDetailsUnavailable] = useState(false); const [expanded, setExpanded] = useState( - () => forceExpanded || (!compactMode && shouldAutoExpand(tool)), + () => forceExpanded || shouldAutoExpand(tool), ); const monitorDetailsRequestRef = useRef(null); // Set once the user explicitly toggles this row, so auto-collapse-on- @@ -1144,22 +1096,14 @@ export const ToolLine = memo(function ToolLine({ useEffect( () => { - setExpanded( - forceExpanded || (compactMode ? false : shouldAutoExpand(tool)), - ); + setExpanded(forceExpanded || shouldAutoExpand(tool)); setMonitorDetailsUnavailable(false); monitorDetailsRequestRef.current = null; - // A new tool identity (or compact-mode toggle) resets the manual latch. + // A new tool identity resets the manual latch. userToggledRef.current = false; }, // eslint-disable-next-line react-hooks/exhaustive-deps - [ - compactMode, - forceExpanded, - monitorDetailsAvailable, - tool.callId, - tool.toolName, - ], + [forceExpanded, monitorDetailsAvailable, tool.callId, tool.toolName], ); const isAgent = isSubAgentToolCall(tool); const hasApproval = approval && approval.toolCallId === tool.callId; @@ -1405,6 +1349,7 @@ export const ToolLine = memo(function ToolLine({ : undefined } > + {displayName} {isTodo && hasTodoList && ( @@ -1517,7 +1462,6 @@ export const ToolGroup = memo(function ToolGroup({ isLocateFlashing = false, }: ToolGroupProps) { const { t } = useI18n(); - const compactMode = useContext(CompactModeContext); const subagentDetails = useSubagentDetails(); const monitorDetails = useMonitorDetails(); const monitorDetailsAvailable = monitorDetails !== undefined; @@ -1527,7 +1471,11 @@ export const ToolGroup = memo(function ToolGroup({ const monitorDetailsRequestRef = useRef(null); const hasRunningTool = hasActiveAgents(tools); const hasFailedTool = tools.some((tool) => tool.status === 'failed'); - const activeTool = tools.length > 0 ? getActiveTool(tools) : undefined; + const activeTool = + tools.find( + (tool) => + isActiveToolStatus(tool.status) && !isBackgroundSubAgentToolCall(tool), + ) ?? (tools.length > 0 ? getActiveTool(tools) : undefined); const singleTool = tools.length === 1 ? tools[0] : undefined; const singleSubagent = singleTool && isSubAgentToolCall(singleTool) ? singleTool : undefined; @@ -1545,22 +1493,28 @@ export const ToolGroup = memo(function ToolGroup({ singleMonitor && monitorDetailsAvailable && !monitorDetailsUnavailable, ); const opensToolDetails = opensSubagentDetails || opensMonitorDetails; - const summaryIconTool = tools[0] ?? activeTool; + const summaryIconTool = activeTool ?? tools[0]; const liveStartedAtRef = useRef(Date.now()); + const liveAnchorKeyRef = useRef(undefined); + // Fallback anchor for a running group whose active tool carries no start + // time (live path only). It is observation time, not tool start time: it is + // re-anchored during render when the running tool changes so the first + // frame already counts from ~0s instead of jumping back after paint. + const liveAnchorKey = animateSummary ? activeTool?.callId : undefined; + if (liveAnchorKey !== liveAnchorKeyRef.current) { + liveAnchorKeyRef.current = liveAnchorKey; + if (liveAnchorKey !== undefined) liveStartedAtRef.current = Date.now(); + } const summaryNow = useSharedNow(animateSummary); const hasApprovalTool = pendingApproval?.toolCallId && tools.some((t) => toolContainsCallId(t, pendingApproval.toolCallId!)); - const showCompact = compactMode && !hasApprovalTool; const runningDuration = animateSummary - ? formatLiveElapsed(summaryNow - liveStartedAtRef.current) + ? formatLiveElapsed( + summaryNow - (activeTool?.startTime ?? liveStartedAtRef.current), + ) : undefined; - useEffect(() => { - if (!animateSummary) return; - liveStartedAtRef.current = Date.now(); - }, [animateSummary, activeTool?.callId]); - useEffect(() => { setMonitorDetailsUnavailable(false); setChatExpanded(false); @@ -1579,16 +1533,6 @@ export const ToolGroup = memo(function ToolGroup({ ); }; - if (showCompact) { - return ( - - ); - } - if (!hasApprovalTool) { return (
@@ -1637,7 +1581,7 @@ export const ToolGroup = memo(function ToolGroup({ workspaceCwd={workspaceCwd} /> ) : ( - formatToolGroupSummary(tools, t, runningDuration) + formatToolGroupSummary(tools, t, runningDuration, workspaceCwd) )} +
{t('shell.command')} {command && {command}}
- {compactMode ? ( -
{t('compact.hint')}
- ) : ( - output &&
{output}
- )} + {output &&
{output}
}
); }); diff --git a/packages/web-shell/client/components/messages/tools/SubAgentPanel.test.tsx b/packages/web-shell/client/components/messages/tools/SubAgentPanel.test.tsx index 2ecf5d66d0d..83ece04ff31 100644 --- a/packages/web-shell/client/components/messages/tools/SubAgentPanel.test.tsx +++ b/packages/web-shell/client/components/messages/tools/SubAgentPanel.test.tsx @@ -6,12 +6,15 @@ import { I18nProvider } from '../../../i18n'; import type { ACPToolCall } from '../../../adapters/types'; import { formatTimestamp } from '../../MessageTimestamp'; -// SubAgentPanel pulls in ToolGroup, which imports App only for -// CompactModeContext; loading the real App module would drag the whole -// application graph into this unit test. +// SubAgentPanel pulls in ToolGroup, which imports App for TodoTimelineContext; +// loading the real App module would drag the whole application graph into this +// unit test. vi.mock('../../../App', async () => { const { createContext } = await import('react'); - return { CompactModeContext: createContext(false) }; + return { + TodoTimelineContext: createContext(new Map()), + TodoDetailContext: createContext(new Map()), + }; }); const { SubAgentPanel } = await import('./SubAgentPanel'); diff --git a/packages/web-shell/client/components/messages/tools/ToolChrome.module.css b/packages/web-shell/client/components/messages/tools/ToolChrome.module.css index 9e9e0a3c3e6..4f5be7e9858 100644 --- a/packages/web-shell/client/components/messages/tools/ToolChrome.module.css +++ b/packages/web-shell/client/components/messages/tools/ToolChrome.module.css @@ -41,13 +41,15 @@ } .chatSummary:hover, -.chatSummary:focus-visible { +.chatSummary:focus-visible, +.chatSummary[aria-expanded='true'] { color: var(--primary); outline: none; } .chatSummary:hover .chatSummaryTextActive, -.chatSummary:focus-visible .chatSummaryTextActive { +.chatSummary:focus-visible .chatSummaryTextActive, +.chatSummary[aria-expanded='true'] .chatSummaryTextActive { background-image: none; -webkit-text-fill-color: currentColor; } @@ -70,6 +72,7 @@ width: 14px; height: 14px; display: block; + flex-shrink: 0; } .chatSummaryText { @@ -427,32 +430,3 @@ .expandedCardBody .todoBody { padding: 0; } - -.compactGroup { - margin-bottom: 12px; - padding: 8px 14px; - border: 1px solid var(--border); - border-radius: var(--radius); -} - -.compactHeader { - display: flex; - align-items: baseline; - gap: 8px; - min-width: 0; - color: var(--muted-foreground); - font-size: 13px; - font-weight: 400; -} - -.compactCount { - color: var(--muted-foreground); - font-size: 13px; - flex-shrink: 0; -} - -.compactHint { - font-size: 12px; - color: var(--muted-foreground); - margin-top: 4px; -} diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 846ed7be4c3..bb0d8b886ca 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -1444,7 +1444,6 @@ const EN: Messages = { 'shell.command': 'Shell Command', 'compact.enabled': 'Compact mode enabled', 'compact.disabled': 'Compact mode disabled', - 'compact.hint': 'Press Ctrl+O to show full tool output', 'compact.saveFailed': 'Failed to save compact mode', 'help.subcommands': 'subcommands', 'help.tab.commands': 'Built-in commands', @@ -4205,7 +4204,6 @@ const ZH: Messages = { 'shell.command': 'Shell 命令', 'compact.enabled': '紧凑模式已开启', 'compact.disabled': '紧凑模式已关闭', - 'compact.hint': '按 Ctrl+O 显示完整工具输出', 'compact.saveFailed': '保存紧凑模式失败', 'help.subcommands': '子命令', 'help.tab.commands': '内置命令',