From 36c9da213c67e14b276ea9c6f6bbb3baa950a52c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Mon, 10 Aug 2026 20:13:21 +0800 Subject: [PATCH 1/8] feat(web-shell): improve thinking and tool progress display --- .../web-shell-thinking-and-tool-progress.md | 17 ++ .../src/daemon/ui/transcript.ts | 44 ++++-- .../sdk-typescript/src/daemon/ui/types.ts | 2 + .../sdk-typescript/test/unit/daemonUi.test.ts | 84 ++++++++++ packages/web-shell/client/App.test.tsx | 65 ++++++++ packages/web-shell/client/App.tsx | 146 +++++++++--------- .../web-shell/client/adapters/messageTypes.ts | 2 + .../adapters/transcriptToMessages.test.ts | 110 +++++++++++++ .../client/adapters/transcriptToMessages.ts | 62 +++++++- .../components/MessageItem.dom.test.tsx | 99 +++++++++++- .../client/components/MessageItem.tsx | 13 +- .../components/MessageList.dom.test.tsx | 98 +++++++++++- .../client/components/MessageList.tsx | 47 ++++-- .../components/MessageTimestamp.module.css | 4 + .../components/MessageTimestamp.test.tsx | 27 +++- .../client/components/MessageTimestamp.tsx | 19 ++- .../components/WebShellTranscript.test.tsx | 5 - .../client/components/WebShellTranscript.tsx | 42 +++-- .../components/dialogs/HelpDialog.test.tsx | 36 +++++ .../client/components/dialogs/HelpDialog.tsx | 1 + .../messages/AssistantMessage.test.tsx | 47 ++++-- .../components/messages/AssistantMessage.tsx | 38 +++-- .../components/messages/ToolGroup.test.tsx | 130 +++++++++++++++- .../client/components/messages/ToolGroup.tsx | 131 ++++------------ .../messages/UserShellMessage.module.css | 11 -- .../components/messages/UserShellMessage.tsx | 16 +- .../messages/tools/SubAgentPanel.test.tsx | 8 +- .../messages/tools/ToolChrome.module.css | 36 +---- packages/web-shell/client/customization.tsx | 1 + packages/web-shell/client/i18n.tsx | 12 +- 30 files changed, 995 insertions(+), 358 deletions(-) create mode 100644 docs/design/web-shell-thinking-and-tool-progress.md create mode 100644 packages/web-shell/client/components/dialogs/HelpDialog.test.tsx 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..a219b361adf --- /dev/null +++ b/docs/design/web-shell-thinking-and-tool-progress.md @@ -0,0 +1,17 @@ +# Web Shell thinking visibility and tool progress + +## Goal + +Let users 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` reuses the existing `Ctrl+O` shortcut for thinking visibility, removes the old Web Shell compact rendering path, and documents the new shortcut in Help. It initializes the preference from `localStorage`, defaults to showing thinking, and does not read or write the compact-mode workspace setting. `MessageList` removes thinking rows only from its rendered item list, leaving the transcript and model behavior unchanged. + +Regular tool groups separated only by hidden thinking are merged within the same activity sequence. 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. Thinking and tool messages use that authoritative pair for completed durations only when it contains a positive elapsed interval. Live durations project the elapsed daemon duration onto the client clock, avoiding mixed-clock subtraction while still surviving transcript replay. Legacy and partial records without a usable daemon pair use the client-time pair. + +## Compatibility + +The default remains to show thinking. Missing, invalid, or unavailable `localStorage` falls back safely. No public prop, URL parameter, settings dependency, shortcut, or package dependency is added. diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 6a615916d49..aacfcdefe39 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,12 @@ 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) { + block.serverTimestamp = event.serverTimestamp; + } else { + block.serverUpdatedAt = event.serverTimestamp; + } } } } @@ -650,7 +651,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 +689,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 +778,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 +889,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] } @@ -1019,6 +1027,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 +1042,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; } @@ -1308,7 +1320,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 } } : {}), }; 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 93b10e7b977..d09b510d42a 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -3270,6 +3270,61 @@ 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: '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('uses assistant.done timestamp when the active assistant block has none', () => { let state = createDaemonTranscriptState({ now: 1 }); state = reduceDaemonTranscriptEvents( @@ -3303,6 +3358,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', () => { diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 4cf52f5697e..dacb12a2394 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -309,6 +309,7 @@ const { isResponding?: boolean; activeTurnStartedAt?: number; } | null, + latestShowThinking: undefined as boolean | undefined, latestAddWorkspaceDialogProps: null as AddWorkspaceDialogTestProps | null, latestToolApprovalKeyboardActive: null as boolean | null, toolApprovalKeyboardActiveHistory: [] as Array, @@ -595,6 +596,7 @@ vi.mock('./components/NewSessionDotField', () => ({ vi.mock('./components/MessageList', async () => { const React = await import('react'); const { useInteractionBlocker } = await import('./interactionBlockContext'); + const { useWebShellCustomization } = await import('./customization'); function InteractionBlockerProbe() { const registerInteractionBlocker = useInteractionBlocker(); const releaseRef = React.useRef<(() => void) | null>(null); @@ -628,6 +630,7 @@ vi.mock('./components/MessageList', async () => { }, ref: React.ForwardedRef<{ scrollToBottom: () => void }>, ) { + testState.latestShowThinking = useWebShellCustomization().showThinking; testState.latestMessageListProps = props; React.useImperativeHandle(ref, () => ({ scrollToBottom: vi.fn() })); return React.createElement( @@ -4230,6 +4233,7 @@ beforeEach(() => { // auto-restore into the next test's App mount. sessionStorage.clear(); localStorage.removeItem('qwen-code-web-shell-chat-width'); + localStorage.removeItem('qwen-code-web-shell-show-thinking'); Object.defineProperty(document, 'hidden', { configurable: true, get: () => false, @@ -4302,6 +4306,7 @@ beforeEach(() => { testState.latestStatusBarTasks = null; testState.latestStatusBarOnOpenTasks = null; testState.latestMessageListProps = null; + testState.latestShowThinking = undefined; testState.latestAddWorkspaceDialogProps = null; testState.latestToolApprovalKeyboardActive = null; testState.toolApprovalKeyboardActiveHistory = []; @@ -4446,6 +4451,66 @@ afterEach(() => { vi.restoreAllMocks(); }); +describe('App thinking visibility', () => { + async function toggleThinking() { + 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 thinking visibility only in localStorage', async () => { + localStorage.setItem('qwen-code-web-shell-show-thinking', 'false'); + renderApp(); + expect(testState.latestShowThinking).toBe(false); + + await toggleThinking(); + + expect(localStorage.getItem('qwen-code-web-shell-show-thinking')).toBe( + 'true', + ); + expect(testState.latestShowThinking).toBe(true); + expect(qualifiedSetWorkspaceSetting).not.toHaveBeenCalled(); + }); + + it.each([null, 'garbage'])( + 'defaults to visible thinking for stored value %s', + async (stored) => { + if (stored !== null) { + localStorage.setItem('qwen-code-web-shell-show-thinking', stored); + } + renderApp(); + expect(testState.latestShowThinking).toBe(true); + + await toggleThinking(); + + expect(localStorage.getItem('qwen-code-web-shell-show-thinking')).toBe( + 'false', + ); + expect(testState.latestShowThinking).toBe(false); + }, + ); + + it('continues when localStorage is unavailable', async () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('unavailable'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('unavailable'); + }); + + renderApp(); + await toggleThinking(); + }); +}); + describe('App plan todos', () => { it('gates the exit-plan workflow on the experimental setting', async () => { const approvedEntries = [ diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index b12cd27e7ea..3b7aabe284e 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -317,8 +317,6 @@ import type { CommandDisplayCategoryOrder } from './utils/commandDisplay'; import { WebShellPortalRootContext } from './portalRoot'; import styles from './App.module.css'; -export const CompactModeContext = createContext(false); - /** * Per-snapshot status diffs (keyed by tool callId or plan message id), so a * history row can render what changed in that snapshot without re-deriving it @@ -457,7 +455,6 @@ function availableSkillInfos(status: { })) .sort((a, b) => a.name.localeCompare(b.name)); } -const COMPACT_MODE_SETTING_KEY = 'ui.compactMode'; const HIDE_TIPS_SETTING_KEY = 'ui.hideTips'; /** Maps each ModelDialogMode to its i18n title key — single source of truth. */ @@ -863,6 +860,7 @@ type ChatWidthMode = `${typeof DEFAULT_CHAT_MAX_WIDTH}` | 'wide'; const CHAT_WIDTH_STORAGE_KEY = 'qwen-code-web-shell-chat-width'; const CHAT_SHELL_HORIZONTAL_PADDING = 40; const SIDEBAR_COLLAPSED_STORAGE_KEY = 'qwen-code-web-shell-sidebar-collapsed'; +const SHOW_THINKING_STORAGE_KEY = 'qwen-code-web-shell-show-thinking'; function resolveSidebarOptions(sidebar: WebShellProps['sidebar']): { enabled: boolean; @@ -917,6 +915,27 @@ function writeSidebarCollapsed(collapsed: boolean): void { } } +function readShowThinking(): boolean { + if (typeof window === 'undefined') return true; + try { + const stored = window.localStorage.getItem(SHOW_THINKING_STORAGE_KEY); + if (stored === 'true') return true; + if (stored === 'false') return false; + } catch { + // localStorage can be unavailable in private or embedded contexts. + } + return true; +} + +function writeShowThinking(value: boolean): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(SHOW_THINKING_STORAGE_KEY, String(value)); + } catch { + // localStorage can be unavailable in private or embedded contexts. + } +} + function getDefaultChatWidthMode(): ChatWidthMode { return `${DEFAULT_CHAT_MAX_WIDTH}`; } @@ -1631,6 +1650,7 @@ export function App({ }: AppProps = {}) { const [chatWidthMode, setChatWidthMode] = useState(readChatWidthMode); + const [showThinking, setShowThinking] = useState(readShowThinking); const [selectedLanguage, setSelectedLanguage] = useState( () => providedLanguage === undefined @@ -1829,6 +1849,7 @@ export function App({ renderComposerFooter, renderFooter, compactThinking, + showThinking, collapseCompletedTurns, markdownTableMode, markdown, @@ -1852,6 +1873,7 @@ export function App({ renderComposerFooter, renderFooter, compactThinking, + showThinking, collapseCompletedTurns, markdownTableMode, markdown, @@ -5810,10 +5832,6 @@ export function App({ } return options; }, [connection.models]); - const [compactMode, setCompactMode] = useState(false); - const compactModeRef = useRef(compactMode); - compactModeRef.current = compactMode; - useEffect(() => { if (providedTheme) { setSelectedTheme(providedTheme); @@ -5890,17 +5908,11 @@ export function App({ store.reset(); }, [store, t]); - const handleToggleCompact = useCallback(() => { - const previous = compactModeRef.current; - const next = !compactModeRef.current; - setCompactMode(next); - setWorkspaceSetting('workspace', COMPACT_MODE_SETTING_KEY, next).catch( - (error: unknown) => { - setCompactMode(previous); - reportError(error, t('compact.saveFailed')); - }, - ); - }, [reportError, setWorkspaceSetting, t]); + const handleToggleThinking = useCallback(() => { + const next = !showThinking; + setShowThinking(next); + writeShowThinking(next); + }, [showThinking]); const handleSetMode = useCallback( (modeId: string) => { @@ -8574,7 +8586,7 @@ export function App({ } if (e.key === 'o') { e.preventDefault(); - handleToggleCompact(); + handleToggleThinking(); return; } if (e.key === 'y') { @@ -8589,7 +8601,7 @@ export function App({ }, [ interactionBlocked, handleClearScreen, - handleToggleCompact, + handleToggleThinking, handleRetry, store, t, @@ -10373,48 +10385,46 @@ export function App({ )} - {/* Share the app-level customization + compact-mode contexts so - split panes render markdown/tool-headers/thinking the same + {/* Share the app-level customization so split panes render + markdown/tool-headers/thinking the same way the single-session chat does (todo contexts stay chat- only — they belong to the outer session, not the panes). */} - - - + )} @@ -10475,14 +10485,13 @@ export function App({ } > - - + - {(() => { const contentClassName = [ styles.content, @@ -10638,9 +10647,8 @@ export function App({ ); })()} - - - + +
{ 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: 1_000, + endTime: 6_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: 1_000, endTime: 6_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('does not merge adjacent thinking blocks from different clocks', () => { + 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', startTime: 100_000, endTime: 101_000 }, + { role: 'thinking', startTime: 1_000, endTime: 6_000 }, + ]); + }); + it('handles nested subagent via parentToolCallId', () => { const messages = transcriptBlocksToDaemonMessages([ toolBlock('parent-start', 'parent-1', 'in_progress', 10, { @@ -2994,6 +3102,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..ba5465e6c04 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -54,7 +54,36 @@ 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) { + return hasServerPair + ? { startTime: serverStart!, endTime: serverEnd! } + : { startTime: block.createdAt, 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 +105,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,21 +115,23 @@ function collectBackgroundAgentTaskUpdates( function applyBackgroundAgentTaskUpdate( tool: DaemonMessageToolCall, update: BackgroundAgentTaskUpdate | undefined, + block: DaemonToolTranscriptBlock, ): void { if (!update) return; + const hasServerPair = + block.serverTimestamp !== undefined && update.serverEndTime !== undefined; + tool.startTime = hasServerPair ? block.serverTimestamp : block.createdAt; + tool.endTime = hasServerPair ? update.serverEndTime : update.clientEndTime; 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', @@ -329,6 +361,7 @@ export function transcriptBlocksToDaemonMessages( const backgroundAgentTaskUpdates = collectBackgroundAgentTaskUpdates(blocks); let currentAssistantIdx: number | null = null; let currentThinkingIdx: number | null = null; + let currentThinkingUsesServerPair = false; // Tool cards are standalone transcript turns. Once a tool is emitted, // the next top-level assistant/thought block must start a fresh assistant // message instead of being appended to text that appeared before the tool. @@ -510,6 +543,8 @@ export function transcriptBlocksToDaemonMessages( case 'thought': { const textBlock = block as DaemonTextTranscriptBlock; + const usesServerPair = + !textBlock.streaming && hasServerTimingPair(textBlock); const parentSubAgent = textBlock.parentToolCallId ? toolsByCallId.get(textBlock.parentToolCallId) : undefined; @@ -521,22 +556,32 @@ export function transcriptBlocksToDaemonMessages( currentThinkingIdx !== null ? messages[currentThinkingIdx] : undefined; - if (target && target.role === 'thinking' && !needsNewContentMessage) { + if ( + target && + target.role === 'thinking' && + !needsNewContentMessage && + currentThinkingUsesServerPair === usesServerPair + ) { + const timing = getTranscriptTiming(textBlock, !textBlock.streaming); messages[currentThinkingIdx!] = { ...target, content: target.content + textBlock.text, isStreaming: textBlock.streaming, + endTime: 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; + currentThinkingUsesServerPair = usesServerPair; needsNewContentMessage = false; } currentAssistantIdx = null; @@ -549,6 +594,7 @@ export function transcriptBlocksToDaemonMessages( applyBackgroundAgentTaskUpdate( toolCall, backgroundAgentTaskUpdates.get(toolCall.callId), + toolBlock, ); const permissionInfo = permissionToolInfoByCallId.get(toolCall.callId); if (permissionInfo?.title) { @@ -997,6 +1043,7 @@ function daemonToolBlockToToolCall( block.status === 'failed' || block.status === 'cancelled' || block.status === 'canceled'; + const timing = getTranscriptTiming(block, isComplete && !isBackgroundAgent); return { callId: block.toolCallId, @@ -1010,8 +1057,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..747909a6c20 100644 --- a/packages/web-shell/client/components/MessageItem.dom.test.tsx +++ b/packages/web-shell/client/components/MessageItem.dom.test.tsx @@ -18,8 +18,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 +65,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, }), }; }); @@ -112,6 +132,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 +221,74 @@ describe('MessageItem selectable wrapper', () => { }); }); +describe('MessageItem tool group spacing', () => { + it('uses larger row spacing only while thinking is hidden', () => { + const hidden = render( + + + {item(toolMsg('hidden'))} + + , + ); + const visible = render( + + + {item(toolMsg('visible'))} + + , + ); + const hiddenAssistant = render( + + + {item(assistantMsg('assistant', 'answer'))} + + , + ); + const defaultTool = render( + {item(toolMsg('default'))}, + ); + + expect( + hidden.firstElementChild?.getAttribute('data-tool-group-spacing'), + ).toBe('true'); + expect( + visible.firstElementChild?.getAttribute('data-tool-group-spacing'), + ).toBe('false'); + expect( + hiddenAssistant.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 3570769953f..e4d70dbaaef 100644 --- a/packages/web-shell/client/components/MessageItem.tsx +++ b/packages/web-shell/client/components/MessageItem.tsx @@ -5,7 +5,10 @@ import type { PermissionRequest, TodoItem, } from '../adapters/types'; -import type { WebShellAssistantTurnFooterRenderInfo } from '../customization'; +import { + useWebShellCustomization, + type WebShellAssistantTurnFooterRenderInfo, +} from '../customization'; import { useI18n } from '../i18n'; import { ErrorBoundary } from './ErrorBoundary'; import { MessageTimestamp } from './MessageTimestamp'; @@ -60,6 +63,7 @@ export const MessageItem = memo(function MessageItem({ generateContent, }: MessageItemProps) { const { t } = useI18n(); + const { showThinking } = useWebShellCustomization(); const body = ((): ReactElement | null => { switch (message.role) { case 'user': @@ -93,6 +97,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} /> @@ -223,6 +229,7 @@ export const MessageItem = memo(function MessageItem({ @@ -322,7 +329,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 5326331c72b..bf0f67fe099 100644 --- a/packages/web-shell/client/components/MessageList.dom.test.tsx +++ b/packages/web-shell/client/components/MessageList.dom.test.tsx @@ -17,12 +17,8 @@ import { WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS } from '../constants/sessions'; import flashStyles from './MessageLocateFlash.module.css'; import styles from './MessageList.module.css'; -// Mock the App context and the heavy row children so this test exercises only -// MessageList's own collapse + deferred-scroll logic, not the whole render tree. -vi.mock('../App', async () => { - const { createContext } = await import('react'); - return { CompactModeContext: createContext(false) }; -}); +// Mock the heavy row children so this test exercises only MessageList's own +// collapse + deferred-scroll logic, not the whole render tree. vi.mock('./MessageItem', async () => { const React = await import('react'); const { useWebShellCustomization } = await import('../customization'); @@ -53,6 +49,7 @@ 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, }, sendFailed ? React.createElement( @@ -470,6 +467,95 @@ describe('MessageList — failed prompt retry', () => { }); }); +describe('MessageList — thinking visibility', () => { + it('hides thinking rows without removing surrounding transcript content', () => { + const container = mount( + [userMsg('u1'), thinkingMsg('t1'), asstMsg('a1')], + undefined, + { customization: { showThinking: 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, + { + customization: { + showThinking: false, + 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-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, + { + customization: { + showThinking: false, + collapseCompletedTurns: false, + }, + }, + ); + + expect(parallelAgentsSummary(container)).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 44146aabf17..e522fec6925 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -1,7 +1,6 @@ import { forwardRef, memo, - useContext, useEffect, useImperativeHandle, useLayoutEffect, @@ -25,7 +24,6 @@ import { isBackgroundSubAgentToolCall, isSubAgentToolCall, } from '../adapters/toolClassification'; -import { CompactModeContext } from '../App'; import { useWebShellCustomization, type WebShellAssistantTurnFooterRenderInfo, @@ -46,9 +44,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'; @@ -259,12 +259,23 @@ function isForceExpandGroup( return false; } -function isHiddenInCompactMode(msg: Message): boolean { - if (msg.role === 'thinking') return true; - return false; +function isThinkingMessage(msg: Message): boolean { + 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( +function mergeToolGroupsAcrossThinking( messages: Message[], pendingApproval: PermissionRequest | null, ): Message[] { @@ -274,8 +285,12 @@ function mergeCompactToolGroups( while (i < messages.length) { const msg = messages[i]; - if (msg.role !== 'tool_group' || isForceExpandGroup(msg, pendingApproval)) { - if (!isHiddenInCompactMode(msg)) { + if ( + msg.role !== 'tool_group' || + isForceExpandGroup(msg, pendingApproval) || + isStandaloneToolGroup(msg) + ) { + if (!isThinkingMessage(msg)) { result.push(msg); } i++; @@ -289,14 +304,15 @@ function mergeCompactToolGroups( while (j < messages.length) { const next = messages[j]; - if (isHiddenInCompactMode(next)) { + if (isThinkingMessage(next)) { j++; continue; } if ( next.role === 'tool_group' && - !isForceExpandGroup(next, pendingApproval) + !isForceExpandGroup(next, pendingApproval) && + !isStandaloneToolGroup(next) ) { mergeableGroups.push(next); lastMergedIdx = j; @@ -320,6 +336,7 @@ function mergeCompactToolGroups( id: mergeableGroups[0].id, role: 'tool_group', tools: mergedTools, + timestamp: mergeableGroups[0].timestamp, }); i = lastMergedIdx + 1; } @@ -2475,13 +2492,14 @@ export const MessageList = memo( ) { const { t } = useI18n(); const transcriptRenderMode = useTranscriptRenderMode(); - const compactMode = useContext(CompactModeContext); + const { collapseCompletedTurns, showThinking } = useWebShellCustomization(); + const hideThinking = showThinking === false; const mergedMessages = useMemo( () => - compactMode - ? mergeCompactToolGroups(messages, pendingApproval) + hideThinking + ? mergeToolGroupsAcrossThinking(messages, pendingApproval) : messages, - [compactMode, messages, pendingApproval], + [hideThinking, messages, pendingApproval], ); const displayItems = useMemo( () => @@ -2716,7 +2734,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/WebShellTranscript.test.tsx b/packages/web-shell/client/components/WebShellTranscript.test.tsx index 7f55e093009..cad0283d66a 100644 --- a/packages/web-shell/client/components/WebShellTranscript.test.tsx +++ b/packages/web-shell/client/components/WebShellTranscript.test.tsx @@ -11,7 +11,6 @@ interface Observation { theme: string; language: string; renderMode: string; - compactMode: boolean; customization: Record; } @@ -21,14 +20,12 @@ const observed = vi.hoisted(() => ({ })); vi.mock('../App', () => ({ - CompactModeContext: createContext(false), TodoDetailContext: createContext(new Map()), TodoTimelineContext: createContext(new Map()), })); vi.mock('./MessageList', async () => { const React = await import('react'); - const { CompactModeContext } = await import('../App'); const { useWebShellCustomization } = await import('../customization'); const { useI18n } = await import('../i18n'); const { useTheme } = await import('../themeContext'); @@ -42,7 +39,6 @@ vi.mock('./MessageList', async () => { theme: useTheme(), language: useI18n().language, renderMode: useTranscriptRenderMode(), - compactMode: React.useContext(CompactModeContext), customization: customization as Record, }); return React.createElement('div', { 'data-testid': 'message-list' }); @@ -169,7 +165,6 @@ describe('WebShellTranscript contract', () => { theme: 'light', language: 'zh-CN', renderMode: 'readonly', - compactMode: false, }); expect(observation.customization).toMatchObject({ compactThinking: true, diff --git a/packages/web-shell/client/components/WebShellTranscript.tsx b/packages/web-shell/client/components/WebShellTranscript.tsx index 9638af0d992..90d5388ac99 100644 --- a/packages/web-shell/client/components/WebShellTranscript.tsx +++ b/packages/web-shell/client/components/WebShellTranscript.tsx @@ -8,11 +8,7 @@ import { type ReactElement, } from 'react'; import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon'; -import { - CompactModeContext, - TodoDetailContext, - TodoTimelineContext, -} from '../App'; +import { TodoDetailContext, TodoTimelineContext } from '../App'; import { WebShellCustomizationProvider, type AssistantTurnFooterRenderer, @@ -238,28 +234,26 @@ function WebShellTranscriptContent({ - +
-
- -
+
- +
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..74902b1a0b2 --- /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', 'Show or hide thinking'], + ['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..c1b9be4cf17 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.thinking'], ['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..76b78913167 100644 --- a/packages/web-shell/client/components/messages/AssistantMessage.test.tsx +++ b/packages/web-shell/client/components/messages/AssistantMessage.test.tsx @@ -7,13 +7,6 @@ import { I18nProvider } from '../../i18n'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); -vi.mock('../../App', async () => { - const { createContext } = await import('react'); - return { - CompactModeContext: createContext(false), - }; -}); - const { AssistantMessage, ThinkingMessage, @@ -93,17 +86,49 @@ 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('Done thinking'); - expect(container.textContent).not.toContain('Thought for'); + expect(container.textContent).toContain('Thought for 5s'); + }); + + 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([ diff --git a/packages/web-shell/client/components/messages/AssistantMessage.tsx b/packages/web-shell/client/components/messages/AssistantMessage.tsx index 2f9c0f6c5f7..a102ae99a82 100644 --- a/packages/web-shell/client/components/messages/AssistantMessage.tsx +++ b/packages/web-shell/client/components/messages/AssistantMessage.tsx @@ -1,14 +1,5 @@ -import { - memo, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Markdown } from './Markdown'; -import { CompactModeContext } from '../../App'; import { useWebShellCustomization, type WebShellAssistantTurnFooterRenderInfo, @@ -189,6 +180,8 @@ interface ThinkingMessageProps { content: string; isStreaming?: boolean; timestamp?: number; + startTime?: number; + endTime?: number; isLocateFlashing?: boolean; generateContent?: SessionContentGenerator; } @@ -225,17 +218,18 @@ export const ThinkingMessage = memo(function ThinkingMessage({ content, isStreaming, timestamp, + startTime, + endTime, isLocateFlashing = false, generateContent, }: ThinkingMessageProps) { const { language, t } = useI18n(); - 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); @@ -243,6 +237,10 @@ export const ThinkingMessage = memo(function ThinkingMessage({ const [translationError, setTranslationError] = useState(false); const translationAbortRef = useRef(undefined); + useEffect(() => { + if (startTime !== undefined) startTimeRef.current = startTime; + }, [startTime]); + useEffect(() => { if (!content || !thinkingActive) return; setNow(Date.now()); @@ -252,6 +250,10 @@ export const ThinkingMessage = memo(function ThinkingMessage({ useEffect(() => { if (!content) return; + if (endTime !== undefined) { + setFinishedAt(endTime); + return; + } if (thinkingActive) { sawActiveRef.current = true; setFinishedAt(null); @@ -260,11 +262,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, @@ -381,7 +385,7 @@ export const ThinkingMessage = memo(function ThinkingMessage({ isLocateFlashing ? ` ${flashStyles.flash}` : '' }`} > - {content && !compactMode && ( + {content && (
{ const { createContext } = await import('react'); return { - CompactModeContext: createContext(false), TodoTimelineContext: createContext(new Map()), TodoDetailContext: createContext(new Map()), }; @@ -150,7 +149,7 @@ describe('tool group summary logic', () => { expect(hasActiveAgents(tools)).toBe(true); expect(getActiveTool(tools).callId).toBe('active'); - expect(formatToolGroupSummary(tools, t)).toBe('Running ReadFile · 2 tools'); + expect(formatToolGroupSummary(tools, t)).toBe('Running ReadFile'); }); it('uses a static summary when only background agents remain active', () => { @@ -183,7 +182,59 @@ describe('tool group summary logic', () => { }), ]; - expect(formatToolGroupSummary(tools, t)).toBe('Running ReadFile · 2 tools'); + expect(formatToolGroupSummary(tools, t)).toBe('Running ReadFile'); + }); + + 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('2 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).toContain('ReadFile package.json'); + expect(summary).toContain('ToolGroup'); + expect(summary).toContain('2 tools'); + expect(summary).not.toContain('agent'); }); it('localizes active tool names in running summaries', () => { @@ -537,6 +588,79 @@ describe('tool kind logic', () => { }); describe('tool row rendering', () => { + it('keeps the aggregate tool summary when thinking is hidden', () => { + 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' }, + }), + ], + { showThinking: false }, + ); + + expect(container.querySelector('button')?.textContent).toContain( + 'package.json', + ); + expect(container.querySelector('button')?.textContent).toContain( + 'ToolGroup', + ); + expect(container.textContent).not.toContain( + 'Press Ctrl+O to show full tool output', + ); + }); + + 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' }), + ]); + + expect(container.querySelector('button')?.textContent).toContain('5s'); + 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..bba4281d47f 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, @@ -593,20 +593,27 @@ export function formatToolGroupSummary( duration?: 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), + ); return t('toolGroup.running', { - name: localizeToolDisplayName(activeTool.toolName, t), - count: tools.length, + name: activeSummaries.join(' · '), + count: foregroundActiveTools.length, duration: duration ?? '', }); } @@ -959,61 +966,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 +1080,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 +1095,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 +1348,7 @@ export const ToolLine = memo(function ToolLine({ : undefined } > + {displayName} {isTodo && hasTodoList && ( @@ -1517,7 +1461,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 +1470,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,21 +1492,15 @@ export const ToolGroup = memo(function ToolGroup({ singleMonitor && monitorDetailsAvailable && !monitorDetailsUnavailable, ); const opensToolDetails = opensSubagentDetails || opensMonitorDetails; - const summaryIconTool = tools[0] ?? activeTool; - const liveStartedAtRef = useRef(Date.now()); + const summaryIconTool = activeTool ?? tools[0]; 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) - : undefined; - - useEffect(() => { - if (!animateSummary) return; - liveStartedAtRef.current = Date.now(); - }, [animateSummary, activeTool?.callId]); + const runningDuration = + animateSummary && activeTool?.startTime !== undefined + ? formatLiveElapsed(summaryNow - activeTool.startTime) + : undefined; useEffect(() => { setMonitorDetailsUnavailable(false); @@ -1579,16 +1520,6 @@ export const ToolGroup = memo(function ToolGroup({ ); }; - if (showCompact) { - return ( - - ); - } - if (!hasApprovalTool) { return (
diff --git a/packages/web-shell/client/components/messages/UserShellMessage.module.css b/packages/web-shell/client/components/messages/UserShellMessage.module.css index b8547c67b42..bb8e76e92fc 100644 --- a/packages/web-shell/client/components/messages/UserShellMessage.module.css +++ b/packages/web-shell/client/components/messages/UserShellMessage.module.css @@ -13,11 +13,6 @@ max-height: 200px; } -.compact { - overflow: hidden; - max-height: none; -} - .header { display: flex; align-items: center; @@ -58,9 +53,3 @@ overflow-x: auto; white-space: pre-wrap; } - -.compactHint { - color: var(--muted-foreground); - font-size: 12px; - line-height: 1.35; -} diff --git a/packages/web-shell/client/components/messages/UserShellMessage.tsx b/packages/web-shell/client/components/messages/UserShellMessage.tsx index 9af4eda7e5e..14cdd89ce12 100644 --- a/packages/web-shell/client/components/messages/UserShellMessage.tsx +++ b/packages/web-shell/client/components/messages/UserShellMessage.tsx @@ -1,5 +1,4 @@ -import { memo, useContext } from 'react'; -import { CompactModeContext } from '../../App'; +import { memo } from 'react'; import { useI18n } from '../../i18n'; import styles from './UserShellMessage.module.css'; @@ -12,25 +11,16 @@ export const UserShellMessage = memo(function UserShellMessage({ command, output, }: UserShellMessageProps) { - const compactMode = useContext(CompactModeContext); const { t } = useI18n(); return ( -
+
{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..3d55b35a613 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,12 @@ 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()) }; }); 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/customization.tsx b/packages/web-shell/client/customization.tsx index ce7b2c85b8b..107c788e2c5 100644 --- a/packages/web-shell/client/customization.tsx +++ b/packages/web-shell/client/customization.tsx @@ -486,6 +486,7 @@ export interface WebShellCustomization { renderComposerFooter?: ComposerFooterRenderer; renderFooter?: FooterRenderer; compactThinking?: boolean; + showThinking?: boolean; /** * Auto-collapse each completed turn's intermediate steps (thinking, tool * calls, mid-turn assistant text) behind a toggle on the prompt row, leaving diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 7c7f1afb346..fae863b43d1 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -1415,7 +1415,7 @@ const EN: Messages = { 'help.shortcut.shell': 'Run shell commands', 'help.shortcut.togglePanel': 'Toggle this panel', 'help.shortcut.retry': 'Retry last request', - 'help.shortcut.compact': 'Toggle compact mode', + 'help.shortcut.thinking': 'Show or hide thinking', 'retry.hint': 'Press Ctrl+Y to retry or click to retry', 'retry.none': 'No failed request to retry.', 'system.taskNotification': 'Task notification', @@ -1441,10 +1441,6 @@ const EN: Messages = { 'error.modelStreamInterrupted': 'Model response stream was interrupted. Please retry.', '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', 'help.tab.custom': 'custom-commands', @@ -4176,7 +4172,7 @@ const ZH: Messages = { 'help.shortcut.shell': '运行 shell 命令', 'help.shortcut.togglePanel': '切换此面板', 'help.shortcut.retry': '重试上次请求', - 'help.shortcut.compact': '切换紧凑模式', + 'help.shortcut.thinking': '显示或隐藏思考过程', 'retry.hint': '按 Ctrl+Y 重试或点击重试', 'retry.none': '没有可重试的失败请求。', 'system.taskNotification': '后台任务通知', @@ -4201,10 +4197,6 @@ const ZH: Messages = { 'error.unknown': '未知错误', 'error.modelStreamInterrupted': '模型响应流已中断,请重试。', 'shell.command': 'Shell 命令', - 'compact.enabled': '紧凑模式已开启', - 'compact.disabled': '紧凑模式已关闭', - 'compact.hint': '按 Ctrl+O 显示完整工具输出', - 'compact.saveFailed': '保存紧凑模式失败', 'help.subcommands': '子命令', 'help.tab.commands': '内置命令', 'help.tab.custom': '自定义命令', From 8060181edc41cfb0ca441d597819c7bb41a2ff21 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Tue, 11 Aug 2026 03:00:43 +0000 Subject: [PATCH 2/8] fix(web-shell): address review feedback on tool timing and summaries (#8872) --- .../adapters/transcriptToMessages.test.ts | 110 ++++++++++++++++++ .../client/adapters/transcriptToMessages.ts | 5 +- .../components/messages/ToolGroup.test.tsx | 15 ++- .../client/components/messages/ToolGroup.tsx | 2 +- .../messages/tools/SubAgentPanel.test.tsx | 5 +- 5 files changed, 128 insertions(+), 9 deletions(-) diff --git a/packages/web-shell/client/adapters/transcriptToMessages.test.ts b/packages/web-shell/client/adapters/transcriptToMessages.test.ts index fc3b02ba905..b9451e98158 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.test.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.test.ts @@ -316,6 +316,79 @@ describe('transcriptBlocksToDaemonMessages', () => { }); }); + it('uses the daemon clock pair 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: 5_000, endTime: 15_000 }], + }); + }); + + 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, { @@ -2007,6 +2080,43 @@ describe('transcriptBlocksToDaemonMessages', () => { ]); }); + it('keeps merged permission placeholders on the tool server clock', () => { + 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: 5_000, endTime: 15_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, { diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts index ba5465e6c04..06150306d4e 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -119,7 +119,9 @@ function applyBackgroundAgentTaskUpdate( ): void { if (!update) return; const hasServerPair = - block.serverTimestamp !== undefined && update.serverEndTime !== undefined; + block.serverTimestamp !== undefined && + update.serverEndTime !== undefined && + update.serverEndTime > block.serverTimestamp; tool.startTime = hasServerPair ? block.serverTimestamp : block.createdAt; tool.endTime = hasServerPair ? update.serverEndTime : update.clientEndTime; switch (update.status) { @@ -941,6 +943,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; diff --git a/packages/web-shell/client/components/messages/ToolGroup.test.tsx b/packages/web-shell/client/components/messages/ToolGroup.test.tsx index 98c04c8e40c..fd5e09fa9c8 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.test.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.test.tsx @@ -149,7 +149,7 @@ describe('tool group summary logic', () => { expect(hasActiveAgents(tools)).toBe(true); expect(getActiveTool(tools).callId).toBe('active'); - expect(formatToolGroupSummary(tools, t)).toBe('Running ReadFile'); + expect(formatToolGroupSummary(tools, t)).toBe('Running ReadFile · 2 tools'); }); it('uses a static summary when only background agents remain active', () => { @@ -182,7 +182,7 @@ describe('tool group summary logic', () => { }), ]; - expect(formatToolGroupSummary(tools, t)).toBe('Running ReadFile'); + expect(formatToolGroupSummary(tools, t)).toBe('Running ReadFile · 2 tools'); }); it('describes every active foreground tool until all tools finish', () => { @@ -205,7 +205,7 @@ describe('tool group summary logic', () => { const summary = formatToolGroupSummary(tools, t); expect(summary).toContain('ReadFile package.json'); expect(summary).toContain('ToolGroup'); - expect(summary).toContain('2 tools'); + expect(summary).toContain('3 tools'); }); it('excludes a running background agent from a multi-tool summary', () => { @@ -233,7 +233,7 @@ describe('tool group summary logic', () => { const summary = formatToolGroupSummary(tools, t); expect(summary).toContain('ReadFile package.json'); expect(summary).toContain('ToolGroup'); - expect(summary).toContain('2 tools'); + expect(summary).toContain('3 tools'); expect(summary).not.toContain('agent'); }); @@ -625,8 +625,11 @@ describe('tool row rendering', () => { makeTool({ callId: 'done', status: 'completed' }), ]); - expect(container.querySelector('button')?.textContent).toContain('5s'); - now.mockRestore(); + try { + expect(container.querySelector('button')?.textContent).toContain('5s'); + } finally { + now.mockRestore(); + } }); it('shows stable elapsed time from persisted tool timestamps', () => { diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index bba4281d47f..e00278c064d 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -613,7 +613,7 @@ export function formatToolGroupSummary( ); return t('toolGroup.running', { name: activeSummaries.join(' · '), - count: foregroundActiveTools.length, + count: tools.length, duration: duration ?? '', }); } 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 3d55b35a613..83ece04ff31 100644 --- a/packages/web-shell/client/components/messages/tools/SubAgentPanel.test.tsx +++ b/packages/web-shell/client/components/messages/tools/SubAgentPanel.test.tsx @@ -11,7 +11,10 @@ import { formatTimestamp } from '../../MessageTimestamp'; // unit test. vi.mock('../../../App', async () => { const { createContext } = await import('react'); - return { TodoTimelineContext: createContext(new Map()) }; + return { + TodoTimelineContext: createContext(new Map()), + TodoDetailContext: createContext(new Map()), + }; }); const { SubAgentPanel } = await import('./SubAgentPanel'); From 418005158881ede6c7794c8519674076c17e7095 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Tue, 11 Aug 2026 06:41:29 +0000 Subject: [PATCH 3/8] fix(web-shell): address round-2 feedback on tool timing and test coverage (#8872) --- .../src/daemon/ui/transcript.ts | 25 ++++++----- .../sdk-typescript/test/unit/daemonUi.test.ts | 32 ++++++++++++++ packages/web-shell/client/App.test.tsx | 2 + .../adapters/transcriptToMessages.test.ts | 37 ++++++++++++++++ .../client/adapters/transcriptToMessages.ts | 3 ++ .../components/MessageList.dom.test.tsx | 13 +++++- .../components/messages/ToolGroup.test.tsx | 43 ++++++++----------- 7 files changed, 119 insertions(+), 36 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index aacfcdefe39..4a60ba446d8 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -938,7 +938,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( @@ -1080,7 +1080,7 @@ function appendShellBlock( ...(event.stream ? { stream: event.stream } : {}), }; appendBlock(state, block); - clearActiveText(state); + clearActiveText(state, undefined, event); } function appendUserShellBlock( @@ -1125,7 +1125,7 @@ function appendUserShellBlock( }; state.pendingUserShellCommand = undefined; appendBlock(state, block); - clearActiveText(state); + clearActiveText(state, undefined, event); } function upsertPermissionBlock( @@ -1167,7 +1167,7 @@ function upsertPermissionBlock( }; appendBlock(state, block); state.permissionBlockByRequestId[event.requestId] = block.id; - clearActiveText(state); + clearActiveText(state, undefined, event); } function resolvePermissionBlock( @@ -1220,7 +1220,7 @@ function resolvePermissionBlock( }; appendBlock(state, block); state.permissionBlockByRequestId[event.requestId] = block.id; - clearActiveText(state); + clearActiveText(state, undefined, event); } function appendStatusBlock( @@ -1275,7 +1275,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. @@ -1299,7 +1299,7 @@ function appendPromptCancelledBlock( : {}), }; appendBlock(state, block); - clearActiveText(state); + clearActiveText(state, undefined, event); } function createTextBlock( @@ -1623,12 +1623,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/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index d09b510d42a..e67e362f237 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -3325,6 +3325,38 @@ describe('daemon UI time schema (PR-B)', () => { }); }); + 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( diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index dacb12a2394..41b29a4f0e6 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -4507,7 +4507,9 @@ describe('App thinking visibility', () => { }); renderApp(); + expect(testState.latestShowThinking).toBe(true); await toggleThinking(); + expect(testState.latestShowThinking).toBe(false); }); }); diff --git a/packages/web-shell/client/adapters/transcriptToMessages.test.ts b/packages/web-shell/client/adapters/transcriptToMessages.test.ts index b9451e98158..59c44335754 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.test.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.test.ts @@ -2117,6 +2117,43 @@ describe('transcriptBlocksToDaemonMessages', () => { ]); }); + it('keeps the tool server clock 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: 5_000, endTime: 15_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, { diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts index 06150306d4e..010f6e83ca9 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -716,6 +716,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) || diff --git a/packages/web-shell/client/components/MessageList.dom.test.tsx b/packages/web-shell/client/components/MessageList.dom.test.tsx index bf0f67fe099..8fc765457c7 100644 --- a/packages/web-shell/client/components/MessageList.dom.test.tsx +++ b/packages/web-shell/client/components/MessageList.dom.test.tsx @@ -50,6 +50,10 @@ vi.mock('./MessageItem', async () => { '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( @@ -472,7 +476,9 @@ describe('MessageList — thinking visibility', () => { const container = mount( [userMsg('u1'), thinkingMsg('t1'), asstMsg('a1')], undefined, - { customization: { showThinking: false } }, + { + customization: { showThinking: false, collapseCompletedTurns: false }, + }, ); expect(container.querySelector('[data-testid="msg-u1"]')).not.toBeNull(); @@ -507,6 +513,11 @@ describe('MessageList — thinking visibility', () => { .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(); }); diff --git a/packages/web-shell/client/components/messages/ToolGroup.test.tsx b/packages/web-shell/client/components/messages/ToolGroup.test.tsx index fd5e09fa9c8..243e28000bf 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.test.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.test.tsx @@ -231,10 +231,9 @@ describe('tool group summary logic', () => { ]; const summary = formatToolGroupSummary(tools, t); - expect(summary).toContain('ReadFile package.json'); - expect(summary).toContain('ToolGroup'); - expect(summary).toContain('3 tools'); - expect(summary).not.toContain('agent'); + expect(summary).toBe( + "Running ReadFile package.json · Grep 'ToolGroup' in path './' · 3 tools", + ); }); it('localizes active tool names in running summaries', () => { @@ -588,24 +587,21 @@ describe('tool kind logic', () => { }); describe('tool row rendering', () => { - it('keeps the aggregate tool summary when thinking is hidden', () => { - 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' }, - }), - ], - { showThinking: false }, - ); + 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', @@ -613,9 +609,6 @@ describe('tool row rendering', () => { expect(container.querySelector('button')?.textContent).toContain( 'ToolGroup', ); - expect(container.textContent).not.toContain( - 'Press Ctrl+O to show full tool output', - ); }); it('continues a running summary timer from the persisted tool start', () => { From 71a64b314b743a19ddf770b5aef8f3d6c8306a5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Tue, 11 Aug 2026 15:07:45 +0800 Subject: [PATCH 4/8] fix(web-shell): address remaining review feedback --- docs/design/ctrl-o-detail-expand/design.md | 2 ++ .../web-shell-thinking-and-tool-progress.md | 2 +- docs/users/configuration/settings.md | 4 +-- .../cli/src/config/settingsSchema.test.ts | 3 ++ packages/cli/src/config/settingsSchema.ts | 14 -------- .../serve/routes/workspace-settings.test.ts | 14 ++++++++ .../src/serve/routes/workspace-settings.ts | 6 +--- .../sdk-typescript/test/unit/daemonUi.test.ts | 32 +++++++++++++++++++ .../schemas/settings.schema.json | 5 --- .../adapters/transcriptToMessages.test.ts | 22 +++++++++++++ .../components/MessageList.dom.test.tsx | 30 +++++++++++++++++ .../components/WebShellTranscript.test.tsx | 2 ++ .../client/components/WebShellTranscript.tsx | 4 +++ .../messages/AssistantMessage.test.tsx | 9 ++++++ .../components/messages/SettingsMessage.tsx | 2 -- packages/web-shell/client/i18n.tsx | 6 ---- 16 files changed, 122 insertions(+), 35 deletions(-) diff --git a/docs/design/ctrl-o-detail-expand/design.md b/docs/design/ctrl-o-detail-expand/design.md index 281437ba66d..84aee175731 100644 --- a/docs/design/ctrl-o-detail-expand/design.md +++ b/docs/design/ctrl-o-detail-expand/design.md @@ -1,5 +1,7 @@ # 设计方案:Ctrl+O 行为重构 —— 对齐 Claude Code 的 Transcript 模型 +> **Web Shell 补充**:后续的 [Web Shell thinking visibility and tool progress](../web-shell-thinking-and-tool-progress.md) 已移除本历史设计中保留的 Web Shell 独立 compact mode。 + > **⚠️ 已被取代(superseded)**:本文档记录的 **`TranscriptView` + `AlternateScreen` 全详情冻结快照屏** 方案已在后续重构(PR #8077)中**移除**。当前实现中,`Ctrl+O`(与 `Alt+T` 共用 `Command.TOGGLE_THINKING_EXPANDED`)不再打开独立的 transcript 屏,而是**就地切换 full-detail 模式**:通过 `ThoughtExpandedContext.allExpanded` 由 `MainContent` 以 `fullDetail` 下传给每个 `HistoryItemDisplay`,在主视图内联展开所有思考块与工具组(并解除工具结果截断),再按一次收起。下文凡描述独立 transcript 屏与 alt-screen 的章节(如 §3.2、§4.2–§4.5、§9 的栈式 commit 拆分)仅作**历史记录**保留,与现行代码不符。 - 分支:`feat/ctrl-o-detail-expand` diff --git a/docs/design/web-shell-thinking-and-tool-progress.md b/docs/design/web-shell-thinking-and-tool-progress.md index a219b361adf..5be7ee4d91c 100644 --- a/docs/design/web-shell-thinking-and-tool-progress.md +++ b/docs/design/web-shell-thinking-and-tool-progress.md @@ -14,4 +14,4 @@ Transcript blocks retain the first and latest daemon timestamps. Thinking and to ## Compatibility -The default remains to show thinking. Missing, invalid, or unavailable `localStorage` falls back safely. No public prop, URL parameter, settings dependency, shortcut, or package dependency is added. +The default remains to show thinking. Missing, invalid, or unavailable `localStorage` falls back safely. The interactive app adds no URL parameter or settings dependency; the read-only `WebShellTranscript` mirrors the presentation option through an optional `showThinking` prop for embedders without the keyboard shortcut. diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 9ef989b567b..f9fc848d663 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -125,7 +125,7 @@ Settings are organized into categories. Most settings should be placed within th | `ui.showCitations` | boolean | Show citations for generated text in the chat. | `false` | | `ui.history.collapseOnResume` | boolean | Whether to collapse history by default when resuming a session. Can be toggled via `/history collapse-on-resume` and `/history expand-on-resume`. | `false` | | `ui.history.collapsePreviewCount` | number | Number of most recent user turns to keep visible when `ui.history.collapseOnResume` is enabled. `0` collapses all restored history by default; `-1` shows all restored history. | `0` | -| `ui.compactMode` | boolean | Retired in the terminal UI. The CLI now always shows the compact, type-based tool view in the main transcript; press `Ctrl+O` to toggle expanded detail mode (expand or collapse all thinking blocks and tool outputs inline) instead of toggling a mode. Still honored by the web shell. | `false` | +| `ui.compactMode` | boolean | Retired and ignored by both the terminal UI and Web Shell. In Web Shell, press `Ctrl+O` to show or hide thinking. | `false` | | `ui.shellOutputMaxLines` | number | Max number of shell output lines shown inline. Set to `0` to disable the cap and show full output. Hidden lines are surfaced via the `+N lines` indicator. Errors, `!`-prefix user-initiated commands, confirming tools, and focused embedded shells always show full output. | `5` | | `ui.enableWelcomeBack` | boolean | Show welcome back dialog when returning to a project with conversation history. When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. If you choose **Start new chat session**, that choice is remembered for the current project until the project summary changes. This feature integrates with the `/summary` command and quit confirmation dialog. | `true` | | `ui.accessibility.enableLoadingPhrases` | boolean | Enable loading phrases (disable for accessibility). | `true` | @@ -138,7 +138,7 @@ Settings are organized into categories. Most settings should be placed within th | `ui.showStatusInTitle` | boolean | Show the Qwen Code session name and status in the terminal window title. | `true` | | `ui.disableWorkflowKeywordTrigger` | boolean | When `true`, mentioning the word `workflow` in a prompt no longer softly steers the turn toward the Workflow tool (and the Footer `workflow active` indicator is suppressed). Only applies when workflows are enabled. | `false` | | `ui.enableUserFeedback` | boolean | Show an optional feedback dialog after conversations to help improve Qwen performance. | `true` | -| `ui.compactInline` | boolean | Compact tool display within each group instead of merging across groups. Requires `ui.compactMode` to be enabled. Requires restart. | `false` | +| `ui.compactInline` | boolean | Retired and ignored with `ui.compactMode`. | `false` | | `ui.useTerminalBuffer` | boolean | Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Enabled by default in compatible interactive terminals to avoid flicker, scroll-storm, and interface freeze on long sessions. Scroll with `Shift+↑/↓` (line), `PgUp`/`PgDn` (page), `Ctrl+Home/End` (top/bottom), or the mouse wheel. Does not use the host terminal scrollback while enabled; drag to select text (double/triple click selects a word/line), or hold `Shift` (or `Option` on macOS) while dragging for the terminal's own selection. Mouse interactions (wheel, drag-select, click, hover) require `ui.mouseTracking` (on by default). | `true` | | `ui.showScrollbar` | boolean | Show the auto-hiding scrollbar in the in-app scrollable viewport (Virtualized History). The bar appears while scrolling and fades out when idle. Disable to hide it entirely. Only applies in the interactive terminal UI. | `true` | | `ui.mouseTracking` | boolean | Enable in-app SGR mouse tracking for text selection, click-to-position in text inputs, row hover, history-item toggling, and viewport scrolling. While enabled, the terminal forwards all mouse events to the app, so native right-click context menus and OSC 8 hyperlink clicks are unavailable. Disable to restore native right-click and clickable URL links; this turns off all in-app mouse interaction, and in Virtualized History the wheel no longer scrolls the transcript — use Shift+↑/↓, PgUp/PgDn, or Ctrl+Home/End instead (pair with `ui.useTerminalBuffer: false` to restore native terminal scrollback). Only applies in the interactive terminal UI. | `true` | diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 2b0dbf6ce15..11d1066afd9 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -444,6 +444,9 @@ describe('SettingsSchema', () => { expect(getSettingsSchema().ui.properties.accessibility.showInDialog).toBe( false, ); + expect(getSettingsSchema().ui.properties).not.toHaveProperty( + 'compactMode', + ); expect( getSettingsSchema().context.properties.fileFiltering.showInDialog, ).toBe(false); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index ebed4159dbf..9ccd6fc0f93 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1061,20 +1061,6 @@ const SETTINGS_SCHEMA = { description: 'The last time the feedback dialog was shown.', showInDialog: false, }, - compactMode: { - type: 'boolean', - label: 'Compact Mode', - category: 'UI', - requiresRestart: false, - default: false, - // Retired from the TUI (compact tool output is now always-on there, and - // Ctrl+O opens the transcript instead of toggling this). Kept as a - // hidden, schema-only setting so the web shell's independent compact - // toggle can still persist via the daemon settings routes (mirrors - // `voiceModel`). Not shown in the TUI settings dialog. - description: 'Compact view (web shell only; not used by the TUI).', - showInDialog: false, - }, useTerminalBuffer: { type: 'boolean', label: 'Virtualized History (reduces flicker on long sessions)', diff --git a/packages/cli/src/serve/routes/workspace-settings.test.ts b/packages/cli/src/serve/routes/workspace-settings.test.ts index 212afb7db03..1edb44e1d47 100644 --- a/packages/cli/src/serve/routes/workspace-settings.test.ts +++ b/packages/cli/src/serve/routes/workspace-settings.test.ts @@ -250,6 +250,20 @@ describe('POST /workspace/settings', () => { expect(persistSetting).not.toHaveBeenCalled(); }); + it('rejects the retired compact mode setting', async () => { + const { app, persistSetting } = makeApp(); + + const res = await request(app).post('/workspace/settings').send({ + scope: 'user', + key: 'ui.compactMode', + value: true, + }); + + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ code: 'disallowed_key' }); + expect(persistSetting).not.toHaveBeenCalled(); + }); + it.each(['ui.mouseTracking', 'ui.showScrollbar'])( 'rejects a TUI-only key (%s) that has no effect in the web shell', async (key) => { diff --git a/packages/cli/src/serve/routes/workspace-settings.ts b/packages/cli/src/serve/routes/workspace-settings.ts index 2adbec738e6..ac6ea02a7bd 100644 --- a/packages/cli/src/serve/routes/workspace-settings.ts +++ b/packages/cli/src/serve/routes/workspace-settings.ts @@ -50,11 +50,7 @@ const TUI_ONLY_SETTINGS = new Set([ // `voiceModel` is `showInDialog: false` (so not in the dialog allowlist), but // the Web Shell `/model --voice` picker needs to read + persist it; the daemon // `/voice/stream` then reads it back via `loadSettings`. -const WEB_SHELL_SETTINGS = new Set([ - 'ui.compactMode', - 'voiceModel', - 'mcpServers', -]); +const WEB_SHELL_SETTINGS = new Set(['voiceModel', 'mcpServers']); const LIVE_WEB_SHELL_SETTINGS = [ 'experimental.liveVoice.enabled', diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index e67e362f237..3092dba37fb 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -3308,6 +3308,11 @@ describe('daemon UI time schema (PR-B)', () => { text: 'thinking', serverTimestamp: 1_000, }, + { + type: 'thought.text.delta', + text: ' more', + serverTimestamp: 3_000, + }, { type: 'assistant.text.delta', text: 'answer', @@ -3696,6 +3701,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/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 57403c445f1..a651c3d843e 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -395,11 +395,6 @@ "type": "number", "default": 0 }, - "compactMode": { - "description": "Compact view (web shell only; not used by the TUI).", - "type": "boolean", - "default": false - }, "useTerminalBuffer": { "description": "Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Enabled by default in compatible interactive terminals to avoid flicker, scroll-storm, and interface freeze on long sessions, after Ctrl+O, after Ctrl+E / Ctrl+F (expand), after window resize, or when alt-tabbing back. Screen reader mode and non-interactive output such as piped stdout or CI use append-only terminal output instead. Scroll with Shift+↑/↓ (line), PgUp/PgDn (page), Ctrl+Home/End (top/bottom), or the mouse wheel. Also enables mouse interactions: click an option in a menu/dialog to select it, hover to highlight it, and click in the prompt to position the cursor. Does NOT use the host terminal scrollback while enabled. Drag to select text in the viewport (double/triple click selects a word/line), copied on release. To use the terminal’s own selection instead, hold Shift (or Option on macOS) while dragging. These mouse interactions are controlled by ui.mouseTracking; disable that setting to restore native right-click and OSC 8 hyperlink clicks.", "type": "boolean", diff --git a/packages/web-shell/client/adapters/transcriptToMessages.test.ts b/packages/web-shell/client/adapters/transcriptToMessages.test.ts index 59c44335754..9cb3b6a364c 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.test.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.test.ts @@ -3063,6 +3063,28 @@ describe('transcriptBlocksToDaemonMessages', () => { }); }); + 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: 1_000, + endTime: 9_000, + }, + ]); + }); + it('projects live daemon timing onto the client clock', () => { const messages = transcriptBlocksToDaemonMessages([ textBlock('t1', 'thought', 'thinking', 100_000, true, { diff --git a/packages/web-shell/client/components/MessageList.dom.test.tsx b/packages/web-shell/client/components/MessageList.dom.test.tsx index 8fc765457c7..411da6f893c 100644 --- a/packages/web-shell/client/components/MessageList.dom.test.tsx +++ b/packages/web-shell/client/components/MessageList.dom.test.tsx @@ -188,6 +188,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', @@ -565,6 +570,31 @@ describe('MessageList — thinking visibility', () => { 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, + { + customization: { + showThinking: false, + 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)', () => { diff --git a/packages/web-shell/client/components/WebShellTranscript.test.tsx b/packages/web-shell/client/components/WebShellTranscript.test.tsx index cad0283d66a..4bf79fac55c 100644 --- a/packages/web-shell/client/components/WebShellTranscript.test.tsx +++ b/packages/web-shell/client/components/WebShellTranscript.test.tsx @@ -152,6 +152,7 @@ describe('WebShellTranscript contract', () => { language="zh" chatMaxWidth={720} compactThinking + showThinking={false} collapseCompletedTurns={false} markdownTableMode="advanced" composerTagIcons={{ file: '/file.svg' }} @@ -168,6 +169,7 @@ describe('WebShellTranscript contract', () => { }); expect(observation.customization).toMatchObject({ compactThinking: true, + showThinking: false, collapseCompletedTurns: false, markdownTableMode: 'advanced', composerTagIcons: { file: '/file.svg' }, diff --git a/packages/web-shell/client/components/WebShellTranscript.tsx b/packages/web-shell/client/components/WebShellTranscript.tsx index 90d5388ac99..5be42477135 100644 --- a/packages/web-shell/client/components/WebShellTranscript.tsx +++ b/packages/web-shell/client/components/WebShellTranscript.tsx @@ -52,6 +52,7 @@ export interface WebShellTranscriptProps { chatMaxWidth?: number; workspaceCwd?: string; compactThinking?: boolean; + showThinking?: boolean; collapseCompletedTurns?: boolean; markdownTableMode?: MarkdownTableMode; virtualScrollThreshold?: number; @@ -102,6 +103,7 @@ function WebShellTranscriptContent({ chatMaxWidth, workspaceCwd = '', compactThinking = false, + showThinking, collapseCompletedTurns = true, markdownTableMode = 'basic', virtualScrollThreshold, @@ -132,6 +134,7 @@ function WebShellTranscriptContent({ renderComposerTagTooltip, renderAssistantTurnFooter, compactThinking, + showThinking, collapseCompletedTurns, markdownTableMode, markdown, @@ -148,6 +151,7 @@ function WebShellTranscriptContent({ renderComposerTagTooltip, renderToolHeaderExtra, renderUserMessageContent, + showThinking, ], ); const rootRef = useRef(null); diff --git a/packages/web-shell/client/components/messages/AssistantMessage.test.tsx b/packages/web-shell/client/components/messages/AssistantMessage.test.tsx index 76b78913167..87b73907503 100644 --- a/packages/web-shell/client/components/messages/AssistantMessage.test.tsx +++ b/packages/web-shell/client/components/messages/AssistantMessage.test.tsx @@ -100,6 +100,15 @@ describe('AssistantMessage thinking logic', () => { 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'); diff --git a/packages/web-shell/client/components/messages/SettingsMessage.tsx b/packages/web-shell/client/components/messages/SettingsMessage.tsx index eb99175c77b..d5ceb313145 100644 --- a/packages/web-shell/client/components/messages/SettingsMessage.tsx +++ b/packages/web-shell/client/components/messages/SettingsMessage.tsx @@ -113,8 +113,6 @@ const SUB_DIALOG_KEYS = new Set([ const HIDDEN_SETTING_KEYS = new Set([ 'ui.hideTips', 'ui.enableUserFeedback', - 'ui.compactMode', - 'ui.compactInline', 'mcpServers', ]); const LIVE_SETTING_KEYS = new Set([ diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index fae863b43d1..c1294fc2a6f 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -5483,12 +5483,6 @@ const ZH: Messages = { 'settings.label.ui.enableFollowupSuggestions': '启用后续建议', 'settings.description.ui.enableFollowupSuggestions': '任务完成后显示上下文相关的后续建议。按 Tab 或右方向键接受,按 Enter 接受并提交。', - 'settings.label.ui.compactMode': '紧凑模式', - 'settings.description.ui.compactMode': - '隐藏工具输出和思考内容,显示更简洁的视图(可用 Ctrl+O 切换)。', - 'settings.label.ui.compactInline': '紧凑内联', - 'settings.description.ui.compactInline': - '在每个分组内紧凑显示工具内容,而不是跨分组合并。需要先启用紧凑模式。', 'settings.label.ui.shellOutputMaxLines': 'Shell 输出最大行数', 'settings.description.ui.shellOutputMaxLines': '内联显示的 shell 输出最大行数。设为 0 可取消限制并显示完整输出;隐藏行数仍会通过 +N lines 指示器展示。', From e2282445c5c3d69d240f70bd2b4b270525e0c561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Tue, 11 Aug 2026 20:01:02 +0800 Subject: [PATCH 5/8] fix(web-shell): preserve compact mode semantics --- docs/design/ctrl-o-detail-expand/design.md | 2 - .../web-shell-thinking-and-tool-progress.md | 10 +- docs/users/configuration/settings.md | 4 +- .../cli/src/config/settingsSchema.test.ts | 3 - packages/cli/src/config/settingsSchema.ts | 14 ++ .../serve/routes/workspace-settings.test.ts | 14 -- .../src/serve/routes/workspace-settings.ts | 6 +- .../schemas/settings.schema.json | 5 + packages/web-shell/client/App.test.tsx | 56 +------ packages/web-shell/client/App.tsx | 140 +++++++++--------- .../components/MessageItem.dom.test.tsx | 36 +++-- .../client/components/MessageItem.tsx | 12 +- .../components/MessageList.dom.test.tsx | 111 +++++++------- .../client/components/MessageList.tsx | 20 +-- .../components/WebShellTranscript.test.tsx | 7 +- .../client/components/WebShellTranscript.tsx | 46 +++--- .../components/dialogs/HelpDialog.test.tsx | 4 +- .../client/components/dialogs/HelpDialog.tsx | 2 +- .../messages/AssistantMessage.test.tsx | 7 + .../components/messages/AssistantMessage.tsx | 14 +- .../components/messages/SettingsMessage.tsx | 2 + packages/web-shell/client/customization.tsx | 1 - packages/web-shell/client/i18n.tsx | 18 ++- 23 files changed, 269 insertions(+), 265 deletions(-) diff --git a/docs/design/ctrl-o-detail-expand/design.md b/docs/design/ctrl-o-detail-expand/design.md index 84aee175731..281437ba66d 100644 --- a/docs/design/ctrl-o-detail-expand/design.md +++ b/docs/design/ctrl-o-detail-expand/design.md @@ -1,7 +1,5 @@ # 设计方案:Ctrl+O 行为重构 —— 对齐 Claude Code 的 Transcript 模型 -> **Web Shell 补充**:后续的 [Web Shell thinking visibility and tool progress](../web-shell-thinking-and-tool-progress.md) 已移除本历史设计中保留的 Web Shell 独立 compact mode。 - > **⚠️ 已被取代(superseded)**:本文档记录的 **`TranscriptView` + `AlternateScreen` 全详情冻结快照屏** 方案已在后续重构(PR #8077)中**移除**。当前实现中,`Ctrl+O`(与 `Alt+T` 共用 `Command.TOGGLE_THINKING_EXPANDED`)不再打开独立的 transcript 屏,而是**就地切换 full-detail 模式**:通过 `ThoughtExpandedContext.allExpanded` 由 `MainContent` 以 `fullDetail` 下传给每个 `HistoryItemDisplay`,在主视图内联展开所有思考块与工具组(并解除工具结果截断),再按一次收起。下文凡描述独立 transcript 屏与 alt-screen 的章节(如 §3.2、§4.2–§4.5、§9 的栈式 commit 拆分)仅作**历史记录**保留,与现行代码不符。 - 分支:`feat/ctrl-o-detail-expand` diff --git a/docs/design/web-shell-thinking-and-tool-progress.md b/docs/design/web-shell-thinking-and-tool-progress.md index 5be7ee4d91c..4c237beb5d8 100644 --- a/docs/design/web-shell-thinking-and-tool-progress.md +++ b/docs/design/web-shell-thinking-and-tool-progress.md @@ -1,17 +1,17 @@ -# Web Shell thinking visibility and tool progress +# Web Shell compact mode and tool progress ## Goal -Let users 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. +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` reuses the existing `Ctrl+O` shortcut for thinking visibility, removes the old Web Shell compact rendering path, and documents the new shortcut in Help. It initializes the preference from `localStorage`, defaults to showing thinking, and does not read or write the compact-mode workspace setting. `MessageList` removes thinking rows only from its rendered item list, leaving the transcript and model behavior unchanged. +`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. -Regular tool groups separated only by hidden thinking are merged within the same activity sequence. 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. +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. Thinking and tool messages use that authoritative pair for completed durations only when it contains a positive elapsed interval. Live durations project the elapsed daemon duration onto the client clock, avoiding mixed-clock subtraction while still surviving transcript replay. Legacy and partial records without a usable daemon pair use the client-time pair. ## Compatibility -The default remains to show thinking. Missing, invalid, or unavailable `localStorage` falls back safely. The interactive app adds no URL parameter or settings dependency; the read-only `WebShellTranscript` mirrors the presentation option through an optional `showThinking` prop for embedders without the keyboard shortcut. +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/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index fc9549aa433..8ef92c17b76 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -125,7 +125,7 @@ Settings are organized into categories. Most settings should be placed within th | `ui.showCitations` | boolean | Show citations for generated text in the chat. | `false` | | `ui.history.collapseOnResume` | boolean | Whether to collapse history by default when resuming a session. Can be toggled via `/history collapse-on-resume` and `/history expand-on-resume`. | `false` | | `ui.history.collapsePreviewCount` | number | Number of most recent user turns to keep visible when `ui.history.collapseOnResume` is enabled. `0` collapses all restored history by default; `-1` shows all restored history. | `0` | -| `ui.compactMode` | boolean | Retired and ignored by both the terminal UI and Web Shell. In Web Shell, press `Ctrl+O` to show or hide thinking. | `false` | +| `ui.compactMode` | boolean | Retired in the terminal UI. The CLI now always shows the compact, type-based tool view in the main transcript; press `Ctrl+O` to toggle expanded detail mode (expand or collapse all thinking blocks and tool outputs inline) instead of toggling a mode. Still honored by the web shell. | `false` | | `ui.shellOutputMaxLines` | number | Max number of shell output lines shown inline. Set to `0` to disable the cap and show full output. Hidden lines are surfaced via the `+N lines` indicator. Errors, `!`-prefix user-initiated commands, confirming tools, and focused embedded shells always show full output. | `5` | | `ui.enableWelcomeBack` | boolean | Show welcome back dialog when returning to a project with conversation history. When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. If you choose **Start new chat session**, that choice is remembered for the current project until the project summary changes. This feature integrates with the `/summary` command and quit confirmation dialog. | `true` | | `ui.accessibility.enableLoadingPhrases` | boolean | Enable loading phrases (disable for accessibility). | `true` | @@ -138,7 +138,7 @@ Settings are organized into categories. Most settings should be placed within th | `ui.showStatusInTitle` | boolean | Show the Qwen Code session name and status in the terminal window title. | `true` | | `ui.disableWorkflowKeywordTrigger` | boolean | When `true`, mentioning the word `workflow` in a prompt no longer softly steers the turn toward the Workflow tool (and the Footer `workflow active` indicator is suppressed). Only applies when workflows are enabled. | `false` | | `ui.enableUserFeedback` | boolean | Show an optional feedback dialog after conversations to help improve Qwen performance. | `true` | -| `ui.compactInline` | boolean | Retired and ignored with `ui.compactMode`. | `false` | +| `ui.compactInline` | boolean | Compact tool display within each group instead of merging across groups. Requires `ui.compactMode` to be enabled. Requires restart. | `false` | | `ui.useTerminalBuffer` | boolean | Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Enabled by default in compatible interactive terminals to avoid flicker, scroll-storm, and interface freeze on long sessions. Scroll with `Shift+↑/↓` (line), `PgUp`/`PgDn` (page), `Ctrl+Home/End` (top/bottom), or the mouse wheel. Does not use the host terminal scrollback while enabled; drag to select text (double/triple click selects a word/line), or hold `Shift` (or `Option` on macOS) while dragging for the terminal's own selection. Mouse interactions (wheel, drag-select, click, hover) require `ui.mouseTracking` (on by default). | `true` | | `ui.showScrollbar` | boolean | Show the auto-hiding scrollbar in the in-app scrollable viewport (Virtualized History). The bar appears while scrolling and fades out when idle. Disable to hide it entirely. Only applies in the interactive terminal UI. | `true` | | `ui.mouseTracking` | boolean | Enable in-app SGR mouse tracking for text selection, click-to-position in text inputs, row hover, history-item toggling, and viewport scrolling. While enabled, the terminal forwards all mouse events to the app, so native right-click context menus and OSC 8 hyperlink clicks are unavailable. Disable to restore native right-click and clickable URL links; this turns off all in-app mouse interaction, and in Virtualized History the wheel no longer scrolls the transcript — use Shift+↑/↓, PgUp/PgDn, or Ctrl+Home/End instead (pair with `ui.useTerminalBuffer: false` to restore native terminal scrollback). Only applies in the interactive terminal UI. | `true` | diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 11d1066afd9..2b0dbf6ce15 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -444,9 +444,6 @@ describe('SettingsSchema', () => { expect(getSettingsSchema().ui.properties.accessibility.showInDialog).toBe( false, ); - expect(getSettingsSchema().ui.properties).not.toHaveProperty( - 'compactMode', - ); expect( getSettingsSchema().context.properties.fileFiltering.showInDialog, ).toBe(false); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 47e8680c547..e8c92f65bbb 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1063,6 +1063,20 @@ const SETTINGS_SCHEMA = { description: 'The last time the feedback dialog was shown.', showInDialog: false, }, + compactMode: { + type: 'boolean', + label: 'Compact Mode', + category: 'UI', + requiresRestart: false, + default: false, + // Retired from the TUI (compact tool output is now always-on there, and + // Ctrl+O opens the transcript instead of toggling this). Kept as a + // hidden, schema-only setting so the web shell's independent compact + // toggle can still persist via the daemon settings routes (mirrors + // `voiceModel`). Not shown in the TUI settings dialog. + description: 'Compact view (web shell only; not used by the TUI).', + showInDialog: false, + }, useTerminalBuffer: { type: 'boolean', label: 'Virtualized History (reduces flicker on long sessions)', diff --git a/packages/cli/src/serve/routes/workspace-settings.test.ts b/packages/cli/src/serve/routes/workspace-settings.test.ts index 1edb44e1d47..212afb7db03 100644 --- a/packages/cli/src/serve/routes/workspace-settings.test.ts +++ b/packages/cli/src/serve/routes/workspace-settings.test.ts @@ -250,20 +250,6 @@ describe('POST /workspace/settings', () => { expect(persistSetting).not.toHaveBeenCalled(); }); - it('rejects the retired compact mode setting', async () => { - const { app, persistSetting } = makeApp(); - - const res = await request(app).post('/workspace/settings').send({ - scope: 'user', - key: 'ui.compactMode', - value: true, - }); - - expect(res.status).toBe(400); - expect(res.body).toMatchObject({ code: 'disallowed_key' }); - expect(persistSetting).not.toHaveBeenCalled(); - }); - it.each(['ui.mouseTracking', 'ui.showScrollbar'])( 'rejects a TUI-only key (%s) that has no effect in the web shell', async (key) => { diff --git a/packages/cli/src/serve/routes/workspace-settings.ts b/packages/cli/src/serve/routes/workspace-settings.ts index ac6ea02a7bd..2adbec738e6 100644 --- a/packages/cli/src/serve/routes/workspace-settings.ts +++ b/packages/cli/src/serve/routes/workspace-settings.ts @@ -50,7 +50,11 @@ const TUI_ONLY_SETTINGS = new Set([ // `voiceModel` is `showInDialog: false` (so not in the dialog allowlist), but // the Web Shell `/model --voice` picker needs to read + persist it; the daemon // `/voice/stream` then reads it back via `loadSettings`. -const WEB_SHELL_SETTINGS = new Set(['voiceModel', 'mcpServers']); +const WEB_SHELL_SETTINGS = new Set([ + 'ui.compactMode', + 'voiceModel', + 'mcpServers', +]); const LIVE_WEB_SHELL_SETTINGS = [ 'experimental.liveVoice.enabled', diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index da413ff89d8..02e47a81148 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -395,6 +395,11 @@ "type": "number", "default": 0 }, + "compactMode": { + "description": "Compact view (web shell only; not used by the TUI).", + "type": "boolean", + "default": false + }, "useTerminalBuffer": { "description": "Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Enabled by default in compatible interactive terminals to avoid flicker, scroll-storm, and interface freeze on long sessions, after Ctrl+O, after Ctrl+E / Ctrl+F (expand), after window resize, or when alt-tabbing back. Screen reader mode and non-interactive output such as piped stdout or CI use append-only terminal output instead. Scroll with Shift+↑/↓ (line), PgUp/PgDn (page), Ctrl+Home/End (top/bottom), or the mouse wheel. Also enables mouse interactions: click an option in a menu/dialog to select it, hover to highlight it, and click in the prompt to position the cursor. Does NOT use the host terminal scrollback while enabled. Drag to select text in the viewport (double/triple click selects a word/line), copied on release. To use the terminal’s own selection instead, hold Shift (or Option on macOS) while dragging. These mouse interactions are controlled by ui.mouseTracking; disable that setting to restore native right-click and OSC 8 hyperlink clicks.", "type": "boolean", diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index af35001464d..3e223e63433 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -328,7 +328,6 @@ const { isResponding?: boolean; activeTurnStartedAt?: number; } | null, - latestShowThinking: undefined as boolean | undefined, latestBtwMessageProps: null as { question: string; answer: string; @@ -645,7 +644,6 @@ vi.mock('./components/NewSessionDotField', () => ({ vi.mock('./components/MessageList', async () => { const React = await import('react'); const { useInteractionBlocker } = await import('./interactionBlockContext'); - const { useWebShellCustomization } = await import('./customization'); function InteractionBlockerProbe() { const registerInteractionBlocker = useInteractionBlocker(); const releaseRef = React.useRef<(() => void) | null>(null); @@ -685,7 +683,6 @@ vi.mock('./components/MessageList', async () => { }, ref: React.ForwardedRef<{ scrollToBottom: () => void }>, ) { - testState.latestShowThinking = useWebShellCustomization().showThinking; testState.latestMessageListProps = props; React.useImperativeHandle(ref, () => ({ scrollToBottom: vi.fn() })); return React.createElement( @@ -4374,7 +4371,6 @@ beforeEach(() => { // auto-restore into the next test's App mount. sessionStorage.clear(); localStorage.removeItem('qwen-code-web-shell-chat-width'); - localStorage.removeItem('qwen-code-web-shell-show-thinking'); Object.defineProperty(document, 'hidden', { configurable: true, get: () => false, @@ -4461,7 +4457,6 @@ beforeEach(() => { testState.latestStatusBarTasks = null; testState.latestStatusBarOnOpenTasks = null; testState.latestMessageListProps = null; - testState.latestShowThinking = undefined; testState.latestBtwMessageProps = null; testState.latestAddWorkspaceDialogProps = null; testState.latestToolApprovalKeyboardActive = null; @@ -4611,8 +4606,8 @@ afterEach(() => { vi.unstubAllGlobals(); }); -describe('App thinking visibility', () => { - async function toggleThinking() { +describe('App compact mode', () => { + async function toggleCompactMode() { await act(async () => { window.dispatchEvent( new KeyboardEvent('keydown', { @@ -4626,50 +4621,15 @@ describe('App thinking visibility', () => { }); } - it('uses Ctrl+O and persists thinking visibility only in localStorage', async () => { - localStorage.setItem('qwen-code-web-shell-show-thinking', 'false'); + it('uses Ctrl+O and persists the existing workspace setting', async () => { renderApp(); - expect(testState.latestShowThinking).toBe(false); + await toggleCompactMode(); - await toggleThinking(); - - expect(localStorage.getItem('qwen-code-web-shell-show-thinking')).toBe( - 'true', + expect(settingsSetValue).toHaveBeenCalledWith( + 'workspace', + 'ui.compactMode', + true, ); - expect(testState.latestShowThinking).toBe(true); - expect(qualifiedSetWorkspaceSetting).not.toHaveBeenCalled(); - }); - - it.each([null, 'garbage'])( - 'defaults to visible thinking for stored value %s', - async (stored) => { - if (stored !== null) { - localStorage.setItem('qwen-code-web-shell-show-thinking', stored); - } - renderApp(); - expect(testState.latestShowThinking).toBe(true); - - await toggleThinking(); - - expect(localStorage.getItem('qwen-code-web-shell-show-thinking')).toBe( - 'false', - ); - expect(testState.latestShowThinking).toBe(false); - }, - ); - - it('continues when localStorage is unavailable', async () => { - vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { - throw new Error('unavailable'); - }); - vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { - throw new Error('unavailable'); - }); - - renderApp(); - expect(testState.latestShowThinking).toBe(true); - await toggleThinking(); - expect(testState.latestShowThinking).toBe(false); }); }); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index b929f933468..78a2d240b8e 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -325,6 +325,8 @@ import type { CommandDisplayCategoryOrder } from './utils/commandDisplay'; import { WebShellPortalRootContext } from './portalRoot'; import styles from './App.module.css'; +export const CompactModeContext = createContext(false); + /** * Per-snapshot status diffs (keyed by tool callId or plan message id), so a * history row can render what changed in that snapshot without re-deriving it @@ -461,6 +463,7 @@ function availableSkillInfos(status: { })) .sort((a, b) => a.name.localeCompare(b.name)); } +const COMPACT_MODE_SETTING_KEY = 'ui.compactMode'; const HIDE_TIPS_SETTING_KEY = 'ui.hideTips'; /** Maps each ModelDialogMode to its i18n title key — single source of truth. */ @@ -867,7 +870,6 @@ type ChatWidthMode = `${typeof DEFAULT_CHAT_MAX_WIDTH}` | 'wide'; const CHAT_WIDTH_STORAGE_KEY = 'qwen-code-web-shell-chat-width'; const CHAT_SHELL_HORIZONTAL_PADDING = 40; const SIDEBAR_COLLAPSED_STORAGE_KEY = 'qwen-code-web-shell-sidebar-collapsed'; -const SHOW_THINKING_STORAGE_KEY = 'qwen-code-web-shell-show-thinking'; function resolveSidebarOptions(sidebar: WebShellProps['sidebar']): { enabled: boolean; @@ -922,27 +924,6 @@ function writeSidebarCollapsed(collapsed: boolean): void { } } -function readShowThinking(): boolean { - if (typeof window === 'undefined') return true; - try { - const stored = window.localStorage.getItem(SHOW_THINKING_STORAGE_KEY); - if (stored === 'true') return true; - if (stored === 'false') return false; - } catch { - // localStorage can be unavailable in private or embedded contexts. - } - return true; -} - -function writeShowThinking(value: boolean): void { - if (typeof window === 'undefined') return; - try { - window.localStorage.setItem(SHOW_THINKING_STORAGE_KEY, String(value)); - } catch { - // localStorage can be unavailable in private or embedded contexts. - } -} - function getDefaultChatWidthMode(): ChatWidthMode { return `${DEFAULT_CHAT_MAX_WIDTH}`; } @@ -1658,7 +1639,6 @@ export function App({ }: AppProps = {}) { const [chatWidthMode, setChatWidthMode] = useState(readChatWidthMode); - const [showThinking, setShowThinking] = useState(readShowThinking); const [selectedLanguage, setSelectedLanguage] = useState( () => providedLanguage === undefined @@ -1858,7 +1838,6 @@ export function App({ renderComposerFooter, renderFooter, compactThinking, - showThinking, collapseCompletedTurns, markdownTableMode, markdown, @@ -1882,7 +1861,6 @@ export function App({ renderComposerFooter, renderFooter, compactThinking, - showThinking, collapseCompletedTurns, markdownTableMode, markdown, @@ -6085,6 +6063,10 @@ export function App({ } return options; }, [connection.models]); + const [compactMode, setCompactMode] = useState(false); + const compactModeRef = useRef(compactMode); + compactModeRef.current = compactMode; + useEffect(() => { if (providedTheme) { setSelectedTheme(providedTheme); @@ -6167,11 +6149,17 @@ export function App({ store.reset(); }, [store, t]); - const handleToggleThinking = useCallback(() => { - const next = !showThinking; - setShowThinking(next); - writeShowThinking(next); - }, [showThinking]); + const handleToggleCompact = useCallback(() => { + const previous = compactModeRef.current; + const next = !compactModeRef.current; + setCompactMode(next); + setWorkspaceSetting('workspace', COMPACT_MODE_SETTING_KEY, next).catch( + (error: unknown) => { + setCompactMode(previous); + reportError(error, t('compact.saveFailed')); + }, + ); + }, [reportError, setWorkspaceSetting, t]); const handleSetMode = useCallback( (modeId: string) => { @@ -9074,7 +9062,7 @@ export function App({ } if (e.key === 'o') { e.preventDefault(); - handleToggleThinking(); + handleToggleCompact(); return; } if (e.key === 'y') { @@ -9089,7 +9077,7 @@ export function App({ }, [ interactionBlocked, handleClearScreen, - handleToggleThinking, + handleToggleCompact, handleRetry, store, t, @@ -10906,43 +10894,45 @@ export function App({
)} - {/* Share the app-level customization so split panes render - markdown/tool-headers/thinking the same + {/* Share the app-level customization + compact-mode contexts so + split panes render markdown/tool-headers/thinking the same way the single-session chat does (todo contexts stay chat- only — they belong to the outer session, not the panes). */} - + + +
)} @@ -11003,13 +10993,14 @@ export function App({ } > - - + + {(() => { const contentClassName = [ styles.content, @@ -11165,8 +11156,9 @@ export function App({ ); })()} - - + + +
{ + 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 @@ -93,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 } @@ -222,26 +228,26 @@ describe('MessageItem selectable wrapper', () => { }); describe('MessageItem tool group spacing', () => { - it('uses larger row spacing only while thinking is hidden', () => { - const hidden = render( + it('uses larger row spacing only in compact mode', () => { + const compact = render( - - {item(toolMsg('hidden'))} - + + {item(toolMsg('compact'))} + , ); - const visible = render( + const regular = render( - - {item(toolMsg('visible'))} - + + {item(toolMsg('regular'))} + , ); - const hiddenAssistant = render( + const compactAssistant = render( - + {item(assistantMsg('assistant', 'answer'))} - + , ); const defaultTool = render( @@ -249,13 +255,13 @@ describe('MessageItem tool group spacing', () => { ); expect( - hidden.firstElementChild?.getAttribute('data-tool-group-spacing'), + compact.firstElementChild?.getAttribute('data-tool-group-spacing'), ).toBe('true'); expect( - visible.firstElementChild?.getAttribute('data-tool-group-spacing'), + regular.firstElementChild?.getAttribute('data-tool-group-spacing'), ).toBe('false'); expect( - hiddenAssistant.firstElementChild?.getAttribute( + compactAssistant.firstElementChild?.getAttribute( 'data-tool-group-spacing', ), ).toBe('false'); diff --git a/packages/web-shell/client/components/MessageItem.tsx b/packages/web-shell/client/components/MessageItem.tsx index e4d70dbaaef..c76d21bc122 100644 --- a/packages/web-shell/client/components/MessageItem.tsx +++ b/packages/web-shell/client/components/MessageItem.tsx @@ -1,14 +1,12 @@ -import { memo, type ReactElement } from 'react'; +import { memo, useContext, type ReactElement } from 'react'; import type { ACPToolCall, Message, PermissionRequest, TodoItem, } from '../adapters/types'; -import { - useWebShellCustomization, - type WebShellAssistantTurnFooterRenderInfo, -} from '../customization'; +import { CompactModeContext } from '../App'; +import type { WebShellAssistantTurnFooterRenderInfo } from '../customization'; import { useI18n } from '../i18n'; import { ErrorBoundary } from './ErrorBoundary'; import { MessageTimestamp } from './MessageTimestamp'; @@ -63,7 +61,7 @@ export const MessageItem = memo(function MessageItem({ generateContent, }: MessageItemProps) { const { t } = useI18n(); - const { showThinking } = useWebShellCustomization(); + const compactMode = useContext(CompactModeContext); const body = ((): ReactElement | null => { switch (message.role) { case 'user': @@ -229,7 +227,7 @@ export const MessageItem = memo(function MessageItem({ diff --git a/packages/web-shell/client/components/MessageList.dom.test.tsx b/packages/web-shell/client/components/MessageList.dom.test.tsx index bee99cf72f1..169ca165f8c 100644 --- a/packages/web-shell/client/components/MessageList.dom.test.tsx +++ b/packages/web-shell/client/components/MessageList.dom.test.tsx @@ -23,8 +23,12 @@ const virtualizerTestState = vi.hoisted(() => ({ renderItems: true, })); -// Mock the heavy row children so this test exercises only +// Mock the App context and the heavy row children so this test exercises only // MessageList's own collapse + deferred-scroll logic, not the whole render tree. +vi.mock('../App', async () => { + const { createContext } = await import('react'); + return { CompactModeContext: createContext(false) }; +}); vi.mock('./MessageItem', async () => { const React = await import('react'); const { useWebShellCustomization } = await import('../customization'); @@ -115,6 +119,7 @@ vi.mock('@tanstack/react-virtual', () => ({ })); const { MessageList } = await import('./MessageList'); +const { CompactModeContext } = await import('../App'); type MessageListHandle = import('./MessageList').MessageListHandle; ( @@ -300,6 +305,7 @@ function mount( includeSubagentToolUsageInMetrics?: boolean; onCanScrollToBottomChange?: (canScrollToBottom: boolean) => void; customization?: WebShellCustomization; + compactMode?: boolean; failedPromptMessageId?: string; onRetryFailedPrompt?: () => void; } = {}, @@ -311,35 +317,37 @@ function mount( root.render( - - - + + + + + , ); @@ -367,15 +375,17 @@ function rerenderMessages( entry.root.render( - - - + + + + + , ); @@ -493,13 +503,14 @@ describe('MessageList — failed prompt retry', () => { }); }); -describe('MessageList — thinking visibility', () => { +describe('MessageList — compact mode', () => { it('hides thinking rows without removing surrounding transcript content', () => { const container = mount( [userMsg('u1'), thinkingMsg('t1'), asstMsg('a1')], undefined, { - customization: { showThinking: false, collapseCompletedTurns: false }, + compactMode: true, + customization: { collapseCompletedTurns: false }, }, ); @@ -521,10 +532,8 @@ describe('MessageList — thinking visibility', () => { ], undefined, { - customization: { - showThinking: false, - collapseCompletedTurns: false, - }, + compactMode: true, + customization: { collapseCompletedTurns: false }, }, ); @@ -578,10 +587,8 @@ describe('MessageList — thinking visibility', () => { ], undefined, { - customization: { - showThinking: false, - collapseCompletedTurns: false, - }, + compactMode: true, + customization: { collapseCompletedTurns: false }, }, ); @@ -599,10 +606,8 @@ describe('MessageList — thinking visibility', () => { ], undefined, { - customization: { - showThinking: false, - collapseCompletedTurns: false, - }, + compactMode: true, + customization: { collapseCompletedTurns: false }, }, ); diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index 37d26ea37f0..f6832628092 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -1,6 +1,7 @@ import { forwardRef, memo, + useContext, useEffect, useImperativeHandle, useLayoutEffect, @@ -24,6 +25,7 @@ import { isBackgroundSubAgentToolCall, isSubAgentToolCall, } from '../adapters/toolClassification'; +import { CompactModeContext } from '../App'; import { useWebShellCustomization, type WebShellAssistantTurnFooterRenderInfo, @@ -259,7 +261,7 @@ function isForceExpandGroup( return false; } -function isThinkingMessage(msg: Message): boolean { +function isHiddenInCompactMode(msg: Message): boolean { return msg.role === 'thinking'; } @@ -275,7 +277,7 @@ function isStandaloneToolGroup(msg: Message): boolean { ); } -function mergeToolGroupsAcrossThinking( +function mergeCompactToolGroups( messages: Message[], pendingApproval: PermissionRequest | null, ): Message[] { @@ -290,7 +292,7 @@ function mergeToolGroupsAcrossThinking( isForceExpandGroup(msg, pendingApproval) || isStandaloneToolGroup(msg) ) { - if (!isThinkingMessage(msg)) { + if (!isHiddenInCompactMode(msg)) { result.push(msg); } i++; @@ -304,7 +306,7 @@ function mergeToolGroupsAcrossThinking( while (j < messages.length) { const next = messages[j]; - if (isThinkingMessage(next)) { + if (isHiddenInCompactMode(next)) { j++; continue; } @@ -2500,14 +2502,14 @@ export const MessageList = memo( ) { const { t } = useI18n(); const transcriptRenderMode = useTranscriptRenderMode(); - const { collapseCompletedTurns, showThinking } = useWebShellCustomization(); - const hideThinking = showThinking === false; + const compactMode = useContext(CompactModeContext); + const { collapseCompletedTurns } = useWebShellCustomization(); const mergedMessages = useMemo( () => - hideThinking - ? mergeToolGroupsAcrossThinking(messages, pendingApproval) + compactMode + ? mergeCompactToolGroups(messages, pendingApproval) : messages, - [hideThinking, messages, pendingApproval], + [compactMode, messages, pendingApproval], ); const displayItems = useMemo( () => diff --git a/packages/web-shell/client/components/WebShellTranscript.test.tsx b/packages/web-shell/client/components/WebShellTranscript.test.tsx index 4bf79fac55c..7f55e093009 100644 --- a/packages/web-shell/client/components/WebShellTranscript.test.tsx +++ b/packages/web-shell/client/components/WebShellTranscript.test.tsx @@ -11,6 +11,7 @@ interface Observation { theme: string; language: string; renderMode: string; + compactMode: boolean; customization: Record; } @@ -20,12 +21,14 @@ const observed = vi.hoisted(() => ({ })); vi.mock('../App', () => ({ + CompactModeContext: createContext(false), TodoDetailContext: createContext(new Map()), TodoTimelineContext: createContext(new Map()), })); vi.mock('./MessageList', async () => { const React = await import('react'); + const { CompactModeContext } = await import('../App'); const { useWebShellCustomization } = await import('../customization'); const { useI18n } = await import('../i18n'); const { useTheme } = await import('../themeContext'); @@ -39,6 +42,7 @@ vi.mock('./MessageList', async () => { theme: useTheme(), language: useI18n().language, renderMode: useTranscriptRenderMode(), + compactMode: React.useContext(CompactModeContext), customization: customization as Record, }); return React.createElement('div', { 'data-testid': 'message-list' }); @@ -152,7 +156,6 @@ describe('WebShellTranscript contract', () => { language="zh" chatMaxWidth={720} compactThinking - showThinking={false} collapseCompletedTurns={false} markdownTableMode="advanced" composerTagIcons={{ file: '/file.svg' }} @@ -166,10 +169,10 @@ describe('WebShellTranscript contract', () => { theme: 'light', language: 'zh-CN', renderMode: 'readonly', + compactMode: false, }); expect(observation.customization).toMatchObject({ compactThinking: true, - showThinking: false, collapseCompletedTurns: false, markdownTableMode: 'advanced', composerTagIcons: { file: '/file.svg' }, diff --git a/packages/web-shell/client/components/WebShellTranscript.tsx b/packages/web-shell/client/components/WebShellTranscript.tsx index 5be42477135..9638af0d992 100644 --- a/packages/web-shell/client/components/WebShellTranscript.tsx +++ b/packages/web-shell/client/components/WebShellTranscript.tsx @@ -8,7 +8,11 @@ import { type ReactElement, } from 'react'; import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon'; -import { TodoDetailContext, TodoTimelineContext } from '../App'; +import { + CompactModeContext, + TodoDetailContext, + TodoTimelineContext, +} from '../App'; import { WebShellCustomizationProvider, type AssistantTurnFooterRenderer, @@ -52,7 +56,6 @@ export interface WebShellTranscriptProps { chatMaxWidth?: number; workspaceCwd?: string; compactThinking?: boolean; - showThinking?: boolean; collapseCompletedTurns?: boolean; markdownTableMode?: MarkdownTableMode; virtualScrollThreshold?: number; @@ -103,7 +106,6 @@ function WebShellTranscriptContent({ chatMaxWidth, workspaceCwd = '', compactThinking = false, - showThinking, collapseCompletedTurns = true, markdownTableMode = 'basic', virtualScrollThreshold, @@ -134,7 +136,6 @@ function WebShellTranscriptContent({ renderComposerTagTooltip, renderAssistantTurnFooter, compactThinking, - showThinking, collapseCompletedTurns, markdownTableMode, markdown, @@ -151,7 +152,6 @@ function WebShellTranscriptContent({ renderComposerTagTooltip, renderToolHeaderExtra, renderUserMessageContent, - showThinking, ], ); const rootRef = useRef(null); @@ -238,26 +238,28 @@ function WebShellTranscriptContent({ -
+
- +
+ +
-
+
diff --git a/packages/web-shell/client/components/dialogs/HelpDialog.test.tsx b/packages/web-shell/client/components/dialogs/HelpDialog.test.tsx index 74902b1a0b2..fc79c0614f1 100644 --- a/packages/web-shell/client/components/dialogs/HelpDialog.test.tsx +++ b/packages/web-shell/client/components/dialogs/HelpDialog.test.tsx @@ -13,8 +13,8 @@ afterEach(() => { describe('HelpDialog shortcuts', () => { it.each([ - ['en', 'Show or hide thinking'], - ['zh-CN', '显示或隐藏思考过程'], + ['en', 'Toggle compact mode'], + ['zh-CN', '切换紧凑模式'], ] as const)('documents Ctrl+O in %s', (language, description) => { const container = document.createElement('div'); containers.push(container); diff --git a/packages/web-shell/client/components/dialogs/HelpDialog.tsx b/packages/web-shell/client/components/dialogs/HelpDialog.tsx index c1b9be4cf17..cd4453e03c2 100644 --- a/packages/web-shell/client/components/dialogs/HelpDialog.tsx +++ b/packages/web-shell/client/components/dialogs/HelpDialog.tsx @@ -84,7 +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.thinking'], + ['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 87b73907503..3f9bb7311ca 100644 --- a/packages/web-shell/client/components/messages/AssistantMessage.test.tsx +++ b/packages/web-shell/client/components/messages/AssistantMessage.test.tsx @@ -7,6 +7,13 @@ import { I18nProvider } from '../../i18n'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +vi.mock('../../App', async () => { + const { createContext } = await import('react'); + return { + CompactModeContext: createContext(false), + }; +}); + const { AssistantMessage, ThinkingMessage, diff --git a/packages/web-shell/client/components/messages/AssistantMessage.tsx b/packages/web-shell/client/components/messages/AssistantMessage.tsx index a102ae99a82..c6f34da3205 100644 --- a/packages/web-shell/client/components/messages/AssistantMessage.tsx +++ b/packages/web-shell/client/components/messages/AssistantMessage.tsx @@ -1,5 +1,14 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + memo, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { Markdown } from './Markdown'; +import { CompactModeContext } from '../../App'; import { useWebShellCustomization, type WebShellAssistantTurnFooterRenderInfo, @@ -224,6 +233,7 @@ export const ThinkingMessage = memo(function ThinkingMessage({ generateContent, }: ThinkingMessageProps) { const { language, t } = useI18n(); + const compactMode = useContext(CompactModeContext); const [thinkingExpanded, setThinkingExpanded] = useState(false); const thinkingActive = isStreaming === true; const startTimeRef = useRef(startTime ?? timestamp ?? Date.now()); @@ -385,7 +395,7 @@ export const ThinkingMessage = memo(function ThinkingMessage({ isLocateFlashing ? ` ${flashStyles.flash}` : '' }`} > - {content && ( + {content && !compactMode && (
Date: Tue, 11 Aug 2026 20:05:01 +0800 Subject: [PATCH 6/8] chore: revert unrelated ACP test formatting --- integration-tests/cli/acp-integration.test.ts | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/integration-tests/cli/acp-integration.test.ts b/integration-tests/cli/acp-integration.test.ts index 3472a1724d3..64dbd3a13fc 100644 --- a/integration-tests/cli/acp-integration.test.ts +++ b/integration-tests/cli/acp-integration.test.ts @@ -791,26 +791,20 @@ function setupAcpTest( // Track which permission requests we've seen const planModeRequests: PermissionRequest[] = []; - const { - sendRequest, - cleanup, - stderr, - sessionUpdates, - permissionRequests, - agent, - } = setupAcpTest(rig, { - permissionHandler: (request) => { - // Track all permission requests for later verification - // Auto-approve exit plan mode requests with "proceed_always" to trigger auto-edit mode - if (request.toolCall?.kind === 'switch_mode') { - planModeRequests.push(request); - // Return proceed_always to switch to auto-edit mode - return { optionId: 'proceed_always' }; - } - // Auto-approve all other requests - return { optionId: 'proceed_once' }; - }, - }); + const { sendRequest, cleanup, stderr, sessionUpdates, permissionRequests, agent } = + setupAcpTest(rig, { + permissionHandler: (request) => { + // Track all permission requests for later verification + // Auto-approve exit plan mode requests with "proceed_always" to trigger auto-edit mode + if (request.toolCall?.kind === 'switch_mode') { + planModeRequests.push(request); + // Return proceed_always to switch to auto-edit mode + return { optionId: 'proceed_always' }; + } + // Auto-approve all other requests + return { optionId: 'proceed_once' }; + }, + }); try { // Initialize From 3795fb38e4ecff43bd267a53c1b9df11e7e4e8bd Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Tue, 11 Aug 2026 16:33:39 +0000 Subject: [PATCH 7/8] fix(web-shell): restore running duration fallback when tool start time is missing (#8872) --- .../client/components/messages/ToolGroup.test.tsx | 14 ++++++++++++++ .../client/components/messages/ToolGroup.tsx | 15 +++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/web-shell/client/components/messages/ToolGroup.test.tsx b/packages/web-shell/client/components/messages/ToolGroup.test.tsx index 243e28000bf..a2e5697426b 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.test.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.test.tsx @@ -625,6 +625,20 @@ describe('tool row rendering', () => { } }); + 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({ diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index e00278c064d..01edd4ed170 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -1493,14 +1493,21 @@ export const ToolGroup = memo(function ToolGroup({ ); const opensToolDetails = opensSubagentDetails || opensMonitorDetails; const summaryIconTool = activeTool ?? tools[0]; + const liveStartedAtRef = useRef(Date.now()); const summaryNow = useSharedNow(animateSummary); const hasApprovalTool = pendingApproval?.toolCallId && tools.some((t) => toolContainsCallId(t, pendingApproval.toolCallId!)); - const runningDuration = - animateSummary && activeTool?.startTime !== undefined - ? formatLiveElapsed(summaryNow - activeTool.startTime) - : undefined; + const runningDuration = animateSummary + ? formatLiveElapsed( + summaryNow - (activeTool?.startTime ?? liveStartedAtRef.current), + ) + : undefined; + + useEffect(() => { + if (!animateSummary) return; + liveStartedAtRef.current = Date.now(); + }, [animateSummary, activeTool?.callId]); useEffect(() => { setMonitorDetailsUnavailable(false); From 9859fe4f3db27355be4a222024709fffba216709 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Wed, 12 Aug 2026 03:22:55 +0000 Subject: [PATCH 8/8] fix(web-shell): anchor completed timing on the client clock (#8872) --- .../web-shell-thinking-and-tool-progress.md | 2 +- .../src/daemon/ui/transcript.ts | 3 + .../sdk-typescript/test/unit/daemonUi.test.ts | 25 +++++++ .../adapters/transcriptToMessages.test.ts | 74 +++++++++++++++---- .../client/adapters/transcriptToMessages.ts | 55 +++++++++----- .../components/messages/AssistantMessage.tsx | 10 +-- .../components/messages/ToolGroup.test.tsx | 21 ++++++ .../client/components/messages/ToolGroup.tsx | 20 +++-- packages/web-shell/client/i18n.tsx | 2 - 9 files changed, 159 insertions(+), 53 deletions(-) diff --git a/docs/design/web-shell-thinking-and-tool-progress.md b/docs/design/web-shell-thinking-and-tool-progress.md index 4c237beb5d8..b855f7c28f5 100644 --- a/docs/design/web-shell-thinking-and-tool-progress.md +++ b/docs/design/web-shell-thinking-and-tool-progress.md @@ -10,7 +10,7 @@ Update the existing Web Shell compact mode to hide transcript thinking without c 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. Thinking and tool messages use that authoritative pair for completed durations only when it contains a positive elapsed interval. Live durations project the elapsed daemon duration onto the client clock, avoiding mixed-clock subtraction while still surviving transcript replay. Legacy and partial records without a usable daemon pair use the client-time pair. +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 diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 4a60ba446d8..97ec814f96e 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -500,6 +500,9 @@ function finalizeStreamingTextBlock( // finalize/status events can be much newer and would skew message times. 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; diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 45d05972669..bfc7922df2b 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -3377,6 +3377,31 @@ describe('daemon UI time schema (PR-B)', () => { }); }); + 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 }), diff --git a/packages/web-shell/client/adapters/transcriptToMessages.test.ts b/packages/web-shell/client/adapters/transcriptToMessages.test.ts index 9cb3b6a364c..77113578207 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.test.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.test.ts @@ -316,7 +316,7 @@ describe('transcriptBlocksToDaemonMessages', () => { }); }); - it('uses the daemon clock pair for replayed background agent notifications', () => { + 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', @@ -348,10 +348,52 @@ describe('transcriptBlocksToDaemonMessages', () => { expect(messages[0]).toMatchObject({ role: 'tool_group', - tools: [{ callId: 'agent-call', startTime: 5_000, endTime: 15_000 }], + 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, { @@ -2080,7 +2122,7 @@ describe('transcriptBlocksToDaemonMessages', () => { ]); }); - it('keeps merged permission placeholders on the tool server clock', () => { + it('keeps merged permission placeholders on the tool block timing', () => { const messages = transcriptBlocksToDaemonMessages([ { id: 'perm-1', @@ -2112,12 +2154,12 @@ describe('transcriptBlocksToDaemonMessages', () => { expect(messages).toMatchObject([ { role: 'tool_group', - tools: [{ callId: 'tc-1', startTime: 5_000, endTime: 15_000 }], + tools: [{ callId: 'tc-1', startTime: 3_000, endTime: 13_000 }], }, ]); }); - it('keeps the tool server clock when the tool block precedes the permission', () => { + 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', @@ -2149,7 +2191,7 @@ describe('transcriptBlocksToDaemonMessages', () => { expect(messages).toMatchObject([ { role: 'tool_group', - tools: [{ callId: 'tc-1', startTime: 5_000, endTime: 15_000 }], + tools: [{ callId: 'tc-1', startTime: 3_000, endTime: 13_000 }], }, ]); }); @@ -3058,8 +3100,8 @@ describe('transcriptBlocksToDaemonMessages', () => { expect(messages[0]).toMatchObject({ role: 'thinking', - startTime: 1_000, - endTime: 6_000, + startTime: 95_000, + endTime: 100_000, }); }); @@ -3079,8 +3121,8 @@ describe('transcriptBlocksToDaemonMessages', () => { { role: 'thinking', content: 'first second', - startTime: 1_000, - endTime: 9_000, + startTime: 95_000, + endTime: 103_000, }, ]); }); @@ -3123,7 +3165,7 @@ describe('transcriptBlocksToDaemonMessages', () => { expect(messages[0]).toMatchObject({ role: 'tool_group', - tools: [{ startTime: 1_000, endTime: 6_000 }], + tools: [{ startTime: 95_000, endTime: 100_000 }], }); }); @@ -3157,7 +3199,7 @@ describe('transcriptBlocksToDaemonMessages', () => { }); }); - it('does not merge adjacent thinking blocks from different clocks', () => { + it('merges adjacent thinking blocks across timing provenance', () => { const messages = transcriptBlocksToDaemonMessages([ textBlock('client', 'thought', 'first', 100_000, false, { updatedAt: 101_000, @@ -3169,8 +3211,12 @@ describe('transcriptBlocksToDaemonMessages', () => { ]); expect(messages).toMatchObject([ - { role: 'thinking', startTime: 100_000, endTime: 101_000 }, - { role: 'thinking', startTime: 1_000, endTime: 6_000 }, + { + role: 'thinking', + content: 'firstsecond', + startTime: 100_000, + endTime: 106_000, + }, ]); }); diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts index 010f6e83ca9..43d4826f0e7 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -76,9 +76,14 @@ function getTranscriptTiming( const serverEnd = block.serverUpdatedAt; const hasServerPair = hasServerTimingPair(block); if (complete) { - return hasServerPair - ? { startTime: serverStart!, endTime: serverEnd! } - : { startTime: block.createdAt, endTime: block.updatedAt }; + 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!) @@ -118,12 +123,6 @@ function applyBackgroundAgentTaskUpdate( block: DaemonToolTranscriptBlock, ): void { if (!update) return; - const hasServerPair = - block.serverTimestamp !== undefined && - update.serverEndTime !== undefined && - update.serverEndTime > block.serverTimestamp; - tool.startTime = hasServerPair ? block.serverTimestamp : block.createdAt; - tool.endTime = hasServerPair ? update.serverEndTime : update.clientEndTime; switch (update.status) { case 'completed': tool.status = 'completed'; @@ -139,7 +138,21 @@ function applyBackgroundAgentTaskUpdate( 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 { @@ -363,7 +376,6 @@ export function transcriptBlocksToDaemonMessages( const backgroundAgentTaskUpdates = collectBackgroundAgentTaskUpdates(blocks); let currentAssistantIdx: number | null = null; let currentThinkingIdx: number | null = null; - let currentThinkingUsesServerPair = false; // Tool cards are standalone transcript turns. Once a tool is emitted, // the next top-level assistant/thought block must start a fresh assistant // message instead of being appended to text that appeared before the tool. @@ -545,8 +557,6 @@ export function transcriptBlocksToDaemonMessages( case 'thought': { const textBlock = block as DaemonTextTranscriptBlock; - const usesServerPair = - !textBlock.streaming && hasServerTimingPair(textBlock); const parentSubAgent = textBlock.parentToolCallId ? toolsByCallId.get(textBlock.parentToolCallId) : undefined; @@ -558,18 +568,24 @@ export function transcriptBlocksToDaemonMessages( currentThinkingIdx !== null ? messages[currentThinkingIdx] : undefined; - if ( - target && - target.role === 'thinking' && - !needsNewContentMessage && - currentThinkingUsesServerPair === usesServerPair - ) { + 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: timing.endTime, + endTime: + blockElapsed !== undefined && prevEnd !== undefined + ? prevEnd + blockElapsed + : timing.endTime, }; needsNewContentMessage = false; } else { @@ -583,7 +599,6 @@ export function transcriptBlocksToDaemonMessages( ...timing, }); currentThinkingIdx = messages.length - 1; - currentThinkingUsesServerPair = usesServerPair; needsNewContentMessage = false; } currentAssistantIdx = null; diff --git a/packages/web-shell/client/components/messages/AssistantMessage.tsx b/packages/web-shell/client/components/messages/AssistantMessage.tsx index c6f34da3205..33aff2251a4 100644 --- a/packages/web-shell/client/components/messages/AssistantMessage.tsx +++ b/packages/web-shell/client/components/messages/AssistantMessage.tsx @@ -247,10 +247,6 @@ export const ThinkingMessage = memo(function ThinkingMessage({ const [translationError, setTranslationError] = useState(false); const translationAbortRef = useRef(undefined); - useEffect(() => { - if (startTime !== undefined) startTimeRef.current = startTime; - }, [startTime]); - useEffect(() => { if (!content || !thinkingActive) return; setNow(Date.now()); @@ -259,11 +255,7 @@ export const ThinkingMessage = memo(function ThinkingMessage({ }, [content, thinkingActive]); useEffect(() => { - if (!content) return; - if (endTime !== undefined) { - setFinishedAt(endTime); - return; - } + if (!content || endTime !== undefined) return; if (thinkingActive) { sawActiveRef.current = true; setFinishedAt(null); diff --git a/packages/web-shell/client/components/messages/ToolGroup.test.tsx b/packages/web-shell/client/components/messages/ToolGroup.test.tsx index a2e5697426b..3c810f089d1 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.test.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.test.tsx @@ -236,6 +236,27 @@ describe('tool group summary logic', () => { ); }); + 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({ diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index 01edd4ed170..cadcb9d54b5 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -591,6 +591,7 @@ export function formatToolGroupSummary( tools: ACPToolCall[], t: ReturnType['t'], duration?: string, + workspaceCwd?: string, ): string { if (hasActiveAgents(tools)) { const foregroundActiveTools = tools.filter( @@ -609,7 +610,7 @@ export function formatToolGroupSummary( const activeSummaries = foregroundActiveTools.map((tool) => isAskUserQuestionToolName(tool.toolName) ? t('toolGroup.summary.provideInformation') - : formatSingleToolSummary(tool, t), + : formatSingleToolSummary(tool, t, workspaceCwd), ); return t('toolGroup.running', { name: activeSummaries.join(' · '), @@ -1494,6 +1495,16 @@ export const ToolGroup = memo(function ToolGroup({ const opensToolDetails = opensSubagentDetails || opensMonitorDetails; 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 && @@ -1504,11 +1515,6 @@ export const ToolGroup = memo(function ToolGroup({ ) : undefined; - useEffect(() => { - if (!animateSummary) return; - liveStartedAtRef.current = Date.now(); - }, [animateSummary, activeTool?.callId]); - useEffect(() => { setMonitorDetailsUnavailable(false); setChatExpanded(false); @@ -1575,7 +1581,7 @@ export const ToolGroup = memo(function ToolGroup({ workspaceCwd={workspaceCwd} /> ) : ( - formatToolGroupSummary(tools, t, runningDuration) + formatToolGroupSummary(tools, t, runningDuration, workspaceCwd) )}