From a669957f3d45557900a8255283ad0a2a3f7a14e6 Mon Sep 17 00:00:00 2001 From: ytahdn <1294726970@qq.com> Date: Fri, 14 Aug 2026 11:01:09 +0000 Subject: [PATCH] feat(web-shell): fold thinking into the compact-mode tool summary (#9148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compact mode used to drop thinking messages entirely, so a running turn gave no indication of the thinking step. Keep the thoughts and aggregate them with the adjacent tools into one summary: a streaming thought reads "Thinking…" with the running shimmer, and a completed thought settles into a click-to-expand row in its original interleaved position. The translate action is preserved on both the thinking block and the folded thought rows, and the merged group gets a synthetic id so its expanded state never leaks into non-compact mode. Co-authored-by: 钉萁 --- .../web-shell/client/adapters/messageTypes.ts | 13 + .../client/components/MessageItem.tsx | 30 +- .../components/MessageList.dom.test.tsx | 81 +++- .../client/components/MessageList.tsx | 130 +++--- .../messages/AssistantMessage.module.css | 14 +- .../messages/AssistantMessage.test.tsx | 20 +- .../components/messages/AssistantMessage.tsx | 381 +++++++++--------- .../components/messages/ToolGroup.test.tsx | 116 +++++- .../client/components/messages/ToolGroup.tsx | 141 ++++++- .../messages/tools/ToolChrome.module.css | 188 +++++++++ .../e2e/web-shell.compact-thinking.spec.ts | 103 +++++ 11 files changed, 932 insertions(+), 285 deletions(-) create mode 100644 packages/web-shell/client/e2e/web-shell.compact-thinking.spec.ts diff --git a/packages/web-shell/client/adapters/messageTypes.ts b/packages/web-shell/client/adapters/messageTypes.ts index 5e807a927b..051b827e2d 100644 --- a/packages/web-shell/client/adapters/messageTypes.ts +++ b/packages/web-shell/client/adapters/messageTypes.ts @@ -111,6 +111,19 @@ export interface DaemonToolGroupMessage extends DaemonMessageMeta { id: string; role: 'tool_group'; tools: DaemonMessageToolCall[]; + /** + * Thinking folded into this group like a tool (compact mode). Streaming + * entries carry `isStreaming` so the summary can read "Thinking…" while + * the model works, then settle to a click-to-expand row when done. + * `beforeToolCallId` pins each thought to the tool that follows it so the + * group renders in the original interleaved order; thoughts without one + * trail the last tool. + */ + thoughts?: Array<{ + content: string; + isStreaming?: boolean; + beforeToolCallId?: string; + }>; } export interface DaemonPlanMessage extends DaemonMessageMeta { diff --git a/packages/web-shell/client/components/MessageItem.tsx b/packages/web-shell/client/components/MessageItem.tsx index fbc1af486d..e0f914dd32 100644 --- a/packages/web-shell/client/components/MessageItem.tsx +++ b/packages/web-shell/client/components/MessageItem.tsx @@ -95,7 +95,6 @@ export const MessageItem = memo(function MessageItem({ case 'thinking': return ( ); case 'plan': @@ -371,6 +372,7 @@ function areMessagesEqual(prev: Message, next: Message): boolean { case 'tool_group': return ( next.role === 'tool_group' && + areToolGroupThoughtsEqual(prev.thoughts, next.thoughts) && prev.tools.length === next.tools.length && prev.tools.every((tool, index) => areToolCallsEqual(tool, next.tools[index]), @@ -419,6 +421,32 @@ function areToolCallsEqual( ); } +function areToolGroupThoughtsEqual( + prev: + | Array<{ + content: string; + isStreaming?: boolean; + beforeToolCallId?: string; + }> + | undefined, + next: + | Array<{ + content: string; + isStreaming?: boolean; + beforeToolCallId?: string; + }> + | undefined, +): boolean { + if (prev === next) return true; + if (!prev || !next || prev.length !== next.length) return false; + return prev.every( + (thought, index) => + thought.content === next[index]?.content && + thought.isStreaming === next[index]?.isStreaming && + thought.beforeToolCallId === next[index]?.beforeToolCallId, + ); +} + function areToolListsEqual( prev: ACPToolCall[] | undefined, next: ACPToolCall[] | undefined, diff --git a/packages/web-shell/client/components/MessageList.dom.test.tsx b/packages/web-shell/client/components/MessageList.dom.test.tsx index 5e5db929f6..eeb931350c 100644 --- a/packages/web-shell/client/components/MessageList.dom.test.tsx +++ b/packages/web-shell/client/components/MessageList.dom.test.tsx @@ -506,7 +506,7 @@ describe('MessageList — failed prompt retry', () => { }); describe('MessageList — compact mode', () => { - it('hides thinking rows without removing surrounding transcript content', () => { + it('keeps thinking without adjacent tools visible in compact mode', () => { const container = mount( [userMsg('u1'), thinkingMsg('t1'), asstMsg('a1')], undefined, @@ -517,7 +517,7 @@ describe('MessageList — compact mode', () => { ); expect(container.querySelector('[data-testid="msg-u1"]')).not.toBeNull(); - expect(container.querySelector('[data-testid="msg-t1"]')).toBeNull(); + expect(container.querySelector('[data-testid="msg-t1"]')).not.toBeNull(); expect(container.querySelector('[data-testid="msg-a1"]')).not.toBeNull(); rerenderMessages(container, [ @@ -526,10 +526,14 @@ describe('MessageList — compact mode', () => { thinkingMsg('t2'), asstMsg('a1'), ]); + // With turn collapsing back on, the completed thinking folds behind the + // turn summary instead of hiding the surrounding transcript. expect(container.querySelector('[data-testid="msg-t2"]')).toBeNull(); + expect(container.querySelector('[data-testid="msg-u1"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="msg-a1"]')).not.toBeNull(); }); - it('merges tool groups separated only by hidden thinking', () => { + it('merges tool groups separated by completed thinking', () => { const container = mount( [ userMsg('u1'), @@ -547,20 +551,24 @@ describe('MessageList — compact mode', () => { }, ); - expect(container.querySelector('[data-testid="msg-g1"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="msg-summary-g1"]'), + ).not.toBeNull(); expect(container.querySelector('[data-testid="msg-g2"]')).toBeNull(); expect( container - .querySelector('[data-testid="msg-g1"]') + .querySelector('[data-testid="msg-summary-g1"]') ?.getAttribute('data-timestamp'), ).toBe('1000'); expect( container - .querySelector('[data-testid="msg-g1"]') + .querySelector('[data-testid="msg-summary-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(); + expect( + container.querySelector('[data-testid="msg-summary-g3"]'), + ).not.toBeNull(); }); it('keeps visible thinking and tool groups in transcript order', () => { @@ -621,7 +629,9 @@ describe('MessageList — compact mode', () => { }, ); - expect(container.querySelector('[data-testid="msg-g1"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="msg-summary-g1"]'), + ).not.toBeNull(); expect( container.querySelector('[data-testid="msg-special"]'), ).not.toBeNull(); @@ -645,11 +655,19 @@ describe('MessageList — compact mode', () => { expect( container.querySelector('[data-testid="msg-special"]'), ).not.toBeNull(); + // The completed thinking folds into the adjacent tool group, which keeps + // the tool while the standalone group stays separate. expect( container - .querySelector('[data-testid="msg-g2"]') + .querySelector('[data-testid="msg-special"]') + ?.getAttribute('data-tool-ids'), + ).toBe('call-special'); + expect( + container + .querySelector('[data-testid="msg-summary-t1"]') ?.getAttribute('data-tool-ids'), ).toBe('call-g2'); + expect(container.querySelector('[data-testid="msg-g2"]')).toBeNull(); }); }); @@ -2198,6 +2216,51 @@ describe('MessageList — turn collapse (DOM)', () => { expect(c.textContent).toContain('Processing 3s'); }); + it('folds streaming thinking into the tool summary while it runs', () => { + const c = mount( + [ + userMsg('u1'), + { ...thinkingMsg('t1'), isStreaming: true }, + toolMsg('g1'), + ], + undefined, + { isResponding: true, compactMode: true }, + ); + // Streaming thinking merges into the group like a running tool. + expect( + c + .querySelector('[data-testid="msg-summary-t1"]') + ?.getAttribute('data-tool-ids'), + ).toBe('call-g1'); + expect(c.querySelector('[data-testid="msg-g1"]')).toBeNull(); + }); + + it('folds completed thinking into the merged tool summary in compact mode', () => { + const c = mount( + [userMsg('u1'), thinkingMsg('t1'), toolMsg('g1'), asstMsg('a1')], + undefined, + { isResponding: true, compactMode: true }, + ); + // The thinking and the adjacent tool collapse into one group carrying + // the tool; the standalone thinking row is gone. + expect( + c + .querySelector('[data-testid="msg-summary-t1"]') + ?.getAttribute('data-tool-ids'), + ).toBe('call-g1'); + expect(c.querySelector('[data-testid="msg-g1"]')).toBeNull(); + }); + + it('does not fold completed thinking without adjacent tools', () => { + const c = mount( + [userMsg('u1'), thinkingMsg('t1'), asstMsg('a1')], + undefined, + { isResponding: true, compactMode: true }, + ); + // No adjacent tool group: the thinking stays a standalone row. + expect(c.querySelector('[data-testid="msg-t1"]')).not.toBeNull(); + }); + it('toggle round-trip reveals then re-hides the step', () => { const c = mount([userMsg('u1'), toolMsg('g1'), asstMsg('a1')]); click(toggle(c, 'u1')); diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index cf8fa71de0..892fc3683d 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -19,7 +19,12 @@ import { import { createPortal } from 'react-dom'; import { useVirtualizer } from '@tanstack/react-virtual'; import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon'; -import type { Message, ACPToolCall, TurnCollapseHead } from '../adapters/types'; +import type { + ToolGroupMessage as DaemonToolGroupMessage, + Message, + ACPToolCall, + TurnCollapseHead, +} from '../adapters/types'; import type { PermissionRequest } from '../adapters/types'; import { isBackgroundSubAgentToolCall, @@ -267,10 +272,6 @@ function isForceExpandGroup( return false; } -function isHiddenInCompactMode(msg: Message): boolean { - return msg.role === 'thinking'; -} - function isStandaloneToolGroup(msg: Message): boolean { return ( msg.role === 'tool_group' && @@ -290,63 +291,85 @@ function mergeCompactToolGroups( const result: Message[] = []; let i = 0; + const isMergedToolGroup = (m: Message): boolean => + m.role === 'tool_group' && + !isForceExpandGroup(m, pendingApproval) && + !isStandaloneToolGroup(m); + while (i < messages.length) { const msg = messages[i]; + const isThinking = msg.role === 'thinking'; - if ( - msg.role !== 'tool_group' || - isForceExpandGroup(msg, pendingApproval) || - isStandaloneToolGroup(msg) - ) { - if (!isHiddenInCompactMode(msg)) { - result.push(msg); - } - i++; - continue; - } - - const mergeableGroups: Message[] = [msg]; - let lastMergedIdx = i; - let j = i + 1; - - while (j < messages.length) { - const next = messages[j]; - - if (isHiddenInCompactMode(next)) { - j++; - continue; - } - - if ( - next.role === 'tool_group' && - !isForceExpandGroup(next, pendingApproval) && - !isStandaloneToolGroup(next) - ) { - mergeableGroups.push(next); - lastMergedIdx = j; - j++; - continue; - } - - break; - } - - if (mergeableGroups.length === 1) { + if (!isThinking && !isMergedToolGroup(msg)) { result.push(msg); i++; continue; } - const mergedTools = mergeableGroups.flatMap((g) => - g.role === 'tool_group' ? g.tools : [], + // A run of thinking + adjacent tool groups aggregates into one summary, + // keeping the original interleaved order. + const run: Message[] = []; + let lastRunIdx = i - 1; + let j = i; + while (j < messages.length) { + const next = messages[j]; + if (next.role === 'thinking' || isMergedToolGroup(next)) { + run.push(next); + lastRunIdx = j; + j++; + continue; + } + break; + } + + const tools = run + .filter((m): m is DaemonToolGroupMessage => m.role === 'tool_group') + .flatMap((group) => group.tools); + const hasStreamingThought = run.some( + (m) => m.role === 'thinking' && m.isStreaming === true, ); + if (tools.length === 0 && !hasStreamingThought) { + // Completed thinking with no adjacent tools stays a standalone row. + for (const item of run) result.push(item); + i = lastRunIdx + 1; + continue; + } + + // Each thought remembers the tool that follows it, so the group renders + // in the original order without the view reordering anything. + const thoughts: Array<{ + content: string; + isStreaming?: boolean; + beforeToolCallId?: string; + }> = []; + const thoughtsAwaitingTool: Array<(typeof thoughts)[number]> = []; + for (const item of run) { + if (item.role === 'thinking') { + const thought = { + content: item.content, + ...(item.isStreaming === true ? { isStreaming: true } : {}), + }; + thoughts.push(thought); + thoughtsAwaitingTool.push(thought); + } else if (item.role === 'tool_group' && item.tools.length > 0) { + const firstToolCallId = item.tools[0]!.callId; + for (const thought of thoughtsAwaitingTool) { + thought.beforeToolCallId = firstToolCallId; + } + thoughtsAwaitingTool.length = 0; + } + } result.push({ - id: mergeableGroups[0].id, + // Synthetic id so the aggregated group never collides with an original + // message key: React then remounts instead of carrying the expanded + // summary state into non-compact mode. + id: `summary-${run[0]!.id}`, role: 'tool_group', - tools: mergedTools, - timestamp: mergeableGroups[0].timestamp, + tools, + ...(thoughts.length > 0 ? { thoughts } : {}), + timestamp: run[0]!.timestamp, }); - i = lastMergedIdx + 1; + i = lastRunIdx + 1; } return result; @@ -1714,6 +1737,13 @@ export function applyTurnCollapse( toolCallCount += itemToolCallCount(item); if (item.type === 'message' && item.message.role === 'thinking') { thinkingCount++; + } else if ( + item.type === 'message' && + item.message.role === 'tool_group' && + item.message.thoughts + ) { + // Compact mode folds thinking into tool summaries; count it too. + thinkingCount += item.message.thoughts.length; } const terminalTimestamp = terminalTurnTimestamp(item); if (terminalTimestamp !== undefined) { diff --git a/packages/web-shell/client/components/messages/AssistantMessage.module.css b/packages/web-shell/client/components/messages/AssistantMessage.module.css index 98fb370f66..8d5d941d7c 100644 --- a/packages/web-shell/client/components/messages/AssistantMessage.module.css +++ b/packages/web-shell/client/components/messages/AssistantMessage.module.css @@ -136,13 +136,25 @@ .translateButton { flex-shrink: 0; margin-left: 7px; - color: var(--foreground); + padding: 0; + border: none; + border-radius: 0; + background: transparent; + color: var(--muted-foreground); cursor: pointer; + font: inherit; + font-size: 12px; + line-height: inherit; opacity: 0; pointer-events: none; transition: opacity 120ms ease; } +.translateButton:hover, +.translateButton:focus-visible { + color: var(--primary); +} + .thinkingHeader:hover .translateButton, .thinkingHeaderExpanded .translateButton, .translateButton:focus-visible, diff --git a/packages/web-shell/client/components/messages/AssistantMessage.test.tsx b/packages/web-shell/client/components/messages/AssistantMessage.test.tsx index d9e1c5d251..ac3d1c94bd 100644 --- a/packages/web-shell/client/components/messages/AssistantMessage.test.tsx +++ b/packages/web-shell/client/components/messages/AssistantMessage.test.tsx @@ -55,7 +55,6 @@ function renderCompletedThinking( const tree = (isStreaming: boolean) => ( { it('keeps replayed completed thinking durationless', () => { const container = render( - , + , ); expect(container.textContent).toContain('Done thinking'); @@ -133,7 +128,6 @@ describe('AssistantMessage thinking logic', () => { const container = render( { }); const container = render( , @@ -227,7 +220,6 @@ describe('AssistantMessage thinking logic', () => { it('only offers translation when the UI language is Chinese', () => { const container = render( , @@ -253,8 +245,7 @@ describe('AssistantMessage thinking logic', () => { }; const container = render( , 'zh-CN', @@ -292,8 +283,7 @@ describe('AssistantMessage thinking logic', () => { }); const container = render( , 'zh-CN', @@ -350,8 +340,7 @@ describe('AssistantMessage thinking logic', () => { }; const container = render( , 'zh-CN', @@ -376,7 +365,6 @@ describe('AssistantMessage thinking logic', () => { it('does not offer translation while thinking is streaming', () => { const container = render( Date.now()); const [finishedAt, setFinishedAt] = useState(null); - const [translationOpen, setTranslationOpen] = useState(false); - const [translation, setTranslation] = useState(); - const [translationLoading, setTranslationLoading] = useState(false); - const [translationThinking, setTranslationThinking] = useState(false); - const [translationError, setTranslationError] = useState(false); - const translationAbortRef = useRef(undefined); useEffect(() => { if (!content || !thinkingActive) return; @@ -279,6 +261,106 @@ export const ThinkingMessage = memo(function ThinkingMessage({ setThinkingExpanded((v) => !v); }, []); + return ( +
+ {content && ( +
+
+
{ + if (event.currentTarget.contains(event.target as Node)) { + handleToggle(); + } + }} + > + + {language === 'zh-CN' && !thinkingActive && generateContent && ( + + )} +
+ {thinkingExpanded && ( +
+
+
+ +
+
+
+ )} +
+
+ )} +
+ ); +}); + +interface ThinkingTranslateButtonProps { + content: string; + generateContent?: SessionContentGenerator; + className?: string; +} + +export function ThinkingTranslateButton({ + content, + generateContent, + className, +}: ThinkingTranslateButtonProps) { + const { language, t } = useI18n(); + const [translationOpen, setTranslationOpen] = useState(false); + const [translation, setTranslation] = useState(); + const [translationLoading, setTranslationLoading] = useState(false); + const [translationThinking, setTranslationThinking] = useState(false); + const [translationError, setTranslationError] = useState(false); + const translationAbortRef = useRef(undefined); + useEffect( () => () => { translationAbortRef.current?.abort(); @@ -288,10 +370,8 @@ export const ThinkingMessage = memo(function ThinkingMessage({ const translate = useCallback( async (force = false) => { - if (isStreaming || !generateContent || (translationLoading && !force)) { - return; - } - const cacheKey = `${language}:${messageId}:${content}`; + if (!generateContent || (translationLoading && !force)) return; + const cacheKey = `${language}:${content}`; const cached = thinkingTranslationCache.get(cacheKey); if (cached && !force) { cacheThinkingTranslation(cacheKey, cached); @@ -348,14 +428,7 @@ export const ThinkingMessage = memo(function ThinkingMessage({ } } }, - [ - content, - generateContent, - isStreaming, - language, - messageId, - translationLoading, - ], + [content, generateContent, language, translationLoading], ); const handleTranslationOpenChange = useCallback( @@ -376,173 +449,91 @@ export const ThinkingMessage = memo(function ThinkingMessage({ }, []); return ( -
- {content && !compactMode && ( -
-
-
{ - if (event.currentTarget.contains(event.target as Node)) { - handleToggle(); - } - }} - > - - {language === 'zh-CN' && !thinkingActive && generateContent && ( - - - - - -
- {t('thinking.translation')} -
- {translationError ? ( -
- {t('thinking.translationFailed')} -
- ) : translation?.text ? ( -
- -
- ) : ( -
- {t( - translationThinking - ? 'thinking.translationThinking' - : 'thinking.translating', - )} -
- )} -
-
- {!translationLoading && translation?.text && ( - <> - - {t('thinking.inputTokens', { - count: translation.inputTokens ?? '--', - })} - - - {t('thinking.outputTokens', { - count: translation.outputTokens ?? '--', - })} - - - )} -
-
- - -
-
-
-
- )} -
- {thinkingExpanded && ( -
-
-
- -
-
-
+ + + + + +
+ {t('thinking.translation')} +
+ {translationError ? ( +
+ {t('thinking.translationFailed')} +
+ ) : translation?.text ? ( +
+ +
+ ) : ( +
+ {t( + translationThinking + ? 'thinking.translationThinking' + : 'thinking.translating', )}
+ )} +
+
+ {!translationLoading && translation?.text && ( + <> + + {t('thinking.inputTokens', { + count: translation.inputTokens ?? '--', + })} + + + {t('thinking.outputTokens', { + count: translation.outputTokens ?? '--', + })} + + + )} +
+
+ + +
- )} -
+ + ); -}); +} export function getThinkingSummaryKey({ isStreaming, @@ -565,7 +556,7 @@ export function formatThinkingDuration(ms: number): string { return sec > 0 ? `${min}m ${sec}s` : `${min}m`; } -function ThinkingDoneIcon() { +export function ThinkingDoneIcon() { return ( , ): HTMLElement { const container = document.createElement('div'); document.body.appendChild(container); @@ -89,7 +94,7 @@ function renderToolGroup( root.render( - + , ); @@ -1519,6 +1524,115 @@ describe('tool row rendering', () => { }); }); +describe('thinking rows in the compact summary', () => { + it('shows a running summary while a thought is streaming', () => { + const container = renderToolGroup( + [ + makeTool({ + callId: 'tool-1', + toolName: 'ReadFile', + status: 'completed', + }), + ], + {}, + [{ content: 'thinking about it', isStreaming: true }], + ); + + expect(container.querySelector('button')?.textContent).toContain( + 'Thinking', + ); + }); + + it('renders a completed thought line that expands its content on click', () => { + const container = renderToolGroup( + [ + makeTool({ + callId: 'tool-1', + toolName: 'ReadFile', + status: 'completed', + }), + ], + {}, + [{ content: 'private chain of thought' }], + ); + + act(() => { + container.querySelector('button')?.click(); + }); + const thoughtHeader = Array.from( + container.querySelectorAll('[role="button"]'), + ).find((el) => + (el as HTMLElement).textContent?.includes('Done thinking'), + ) as HTMLElement; + expect(thoughtHeader).toBeTruthy(); + // Collapsed by default; content appears on click. + expect(container.textContent).not.toContain('private chain of thought'); + act(() => thoughtHeader.click()); + expect(container.textContent).toContain('private chain of thought'); + }); + + it('keeps the single tool compact when thinking is folded in', () => { + const container = renderToolGroup( + [ + makeTool({ + callId: 'tool-1', + toolName: 'ReadFile', + status: 'completed', + content: [ + { + type: 'content', + content: { type: 'text', text: 'DUMPED CONTENT' }, + }, + ], + }), + ], + {}, + [{ content: 'thinking' }], + ); + + act(() => { + container.querySelector('button')?.click(); + }); + // The single tool renders as a compact line, not a force-expanded dump. + expect(container.textContent).not.toContain('DUMPED CONTENT'); + }); + + it('renders thoughts interleaved with their tools in original order', () => { + const container = renderToolGroup( + [ + makeTool({ + callId: 'tool-1', + toolName: 'ReadFile', + status: 'completed', + }), + makeTool({ callId: 'tool-2', toolName: 'Glob', status: 'completed' }), + ], + {}, + [ + { content: 'first thought', beforeToolCallId: 'tool-1' }, + { content: 'second thought', beforeToolCallId: 'tool-2' }, + ], + ); + + act(() => { + container.querySelector('button')?.click(); + for (const header of container.querySelectorAll('[role="button"]')) { + (header as HTMLElement).click(); + } + }); + const text = container.textContent ?? ''; + const positions = [ + 'first thought', + 'ReadFile', + 'second thought', + 'Glob', + ].map((marker) => text.indexOf(marker)); + expect( + positions.every((v, i) => v >= 0 && (i === 0 || v > positions[i - 1]!)), + ).toBe(true); + }); +}); + describe('tool output logic', () => { it('sanitizes read-file languages before building markdown fences', () => { expect(languageForPath('src/App.tsx')).toBe('tsx'); diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index 42155fef71..75d2fd83e2 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -1,4 +1,5 @@ import { + Fragment, memo, useContext, useEffect, @@ -12,6 +13,7 @@ import type { PermissionRequest, TodoItem, } from '../../adapters/types'; +import type { SessionContentGenerator } from './AssistantMessage'; import { hasActiveAgents, isBackgroundSubAgentToolCall, @@ -32,6 +34,7 @@ import { useSubagentDetails } from '../../subagentDetailsContext'; import { useMonitorDetails } from '../../monitorDetailsContext'; import { TodoEventSummary, TodoFullList } from './TodoView'; import { Markdown } from './Markdown'; +import { ThinkingDoneIcon, ThinkingTranslateButton } from './AssistantMessage'; import { formatDurationMs, formatElapsed, @@ -71,9 +74,21 @@ import styles from './tools/ToolChrome.module.css'; interface ToolGroupProps { tools: ACPToolCall[]; + /** + * Thinking aggregated with the tools in this summary (compact mode), in + * the original order. Streaming entries drive the "Thinking…" summary; + * each entry renders as a click-to-expand row. + */ + thoughts?: Array<{ + content: string; + isStreaming?: boolean; + beforeToolCallId?: string; + }>; pendingApproval?: PermissionRequest | null; workspaceCwd?: string; isLocateFlashing?: boolean; + /** Powers the translate action on completed thinking rows (zh-CN). */ + generateContent?: SessionContentGenerator; } function openMonitorDetailsOnce( @@ -1490,11 +1505,78 @@ export const ToolLine = memo(function ToolLine({ ); }, areToolLinePropsEqual); +function ThoughtLine({ + content, + isStreaming, + generateContent, +}: { + content: string; + isStreaming?: boolean; + generateContent?: SessionContentGenerator; +}) { + const { language, t } = useI18n(); + const [expanded, setExpanded] = useState(false); + return ( +
+
setExpanded((value) => !value)} + onKeyDown={(event) => { + // Only the container itself toggles; keys pressed inside nested + // controls (the translate button) keep their own behavior. + if (event.target !== event.currentTarget) return; + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + setExpanded((value) => !value); + }} + > + + + {t(isStreaming ? 'thinking.running' : 'thinking.done')} + + {language === 'zh-CN' && !isStreaming && generateContent && ( + + )} +
+ {expanded && ( +
+ +
+ )} +
+ ); +} + export const ToolGroup = memo(function ToolGroup({ tools, + thoughts, pendingApproval, workspaceCwd, isLocateFlashing = false, + generateContent, }: ToolGroupProps) { const { t } = useI18n(); const subagentDetails = useSubagentDetails(); @@ -1510,7 +1592,11 @@ export const ToolGroup = memo(function ToolGroup({ (tool) => isActiveToolStatus(tool.status) && !isBackgroundSubAgentToolCall(tool), ) ?? (tools.length > 0 ? getActiveTool(tools) : undefined); + // Single-tool identity stays available for the compact summary and the + // subagent/monitor drawer shortcut even when thoughts are folded in; only + // the force-expanded content dump is suppressed for thought groups. const singleTool = tools.length === 1 ? tools[0] : undefined; + const compactToolLines = !!thoughts?.length; const singleSubagent = singleTool && isSubAgentToolCall(singleTool) ? singleTool : undefined; const singleMonitor = @@ -1521,7 +1607,11 @@ export const ToolGroup = memo(function ToolGroup({ (tool) => isActiveToolStatus(tool.status) && !isBackgroundSubAgentToolCall(tool), ); - const animateSummary = hasRunningTool && hasForegroundActiveTool; + const streamingThought = thoughts?.find((thought) => thought.isStreaming); + const animateSummary = + hasRunningTool && hasForegroundActiveTool + ? true + : streamingThought !== undefined; const opensSubagentDetails = Boolean(singleSubagent && subagentDetails); const opensMonitorDetails = Boolean( singleMonitor && monitorDetailsAvailable && !monitorDetailsUnavailable, @@ -1576,7 +1666,9 @@ export const ToolGroup = memo(function ToolGroup({ } >
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 22f780fa8c..be2c80b21d 100644 --- a/packages/web-shell/client/components/messages/tools/ToolChrome.module.css +++ b/packages/web-shell/client/components/messages/tools/ToolChrome.module.css @@ -19,6 +19,194 @@ gap: 2px; } +/* Completed thinking folded into the merged tool summary: a clickable line + like a tool row, expanding to the thought content on click. */ +.chatSummaryThought { + padding: 2px 0; + min-width: 0; +} + +.chatSummaryThoughtHeader { + display: flex; + align-items: center; + gap: 7px; + margin: 0; + padding: 0; + border: none; + border-radius: 0; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + text-align: left; + font: inherit; +} + +.chatSummaryThoughtHeader:hover, +.chatSummaryThoughtHeader:focus-visible { + color: var(--primary); +} + +.chatSummaryThoughtHeader:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 45%, transparent); + outline-offset: 2px; + border-radius: 4px; +} + +.chatSummaryThoughtIcon { + width: 14px; + height: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + line-height: 1; +} + +.chatSummaryThoughtIcon svg { + display: block; +} + +/* The chevron is hover-only and hugs the content, like the thinking block. */ +.chatSummaryThoughtChevronRight, +.chatSummaryThoughtChevronDown { + margin-left: 7px; + width: 14px; + height: 14px; + position: relative; + flex-shrink: 0; + opacity: 0; + transition: opacity 120ms ease; +} + +.chatSummaryThoughtHeader:hover .chatSummaryThoughtChevronRight, +.chatSummaryThoughtHeader:hover .chatSummaryThoughtChevronDown, +.chatSummaryThoughtHeader:focus-within .chatSummaryThoughtChevronRight, +.chatSummaryThoughtHeader:focus-within .chatSummaryThoughtChevronDown { + opacity: 1; +} + +.chatSummaryThoughtLabelActive { + background-image: linear-gradient( + 105deg, + var(--muted-foreground) 0%, + var(--muted-foreground) 46%, + rgba(255, 255, 255, 0.92) 50%, + var(--muted-foreground) 54%, + var(--muted-foreground) 100% + ); + background-size: 300% 100%; + background-position: 150% 0; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: chat-summary-thinking-shine 5s linear infinite; +} + +@keyframes chat-summary-thinking-shine { + 0% { + background-position: 150% 0; + } + 100% { + background-position: -150% 0; + } +} + +.chatSummaryThoughtLabel { + font-size: 13px; + line-height: 1.5; +} + +.chatSummaryThoughtTranslate { + flex-shrink: 0; + margin-left: 7px; + padding: 0; + border: none; + border-radius: 0; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + font: inherit; + font-size: 12px; + line-height: inherit; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease; +} + +.chatSummaryThoughtTranslate:hover, +.chatSummaryThoughtTranslate:focus-visible { + color: var(--primary); +} + +.chatSummaryThoughtHeader:hover .chatSummaryThoughtTranslate, +.chatSummaryThoughtHeaderExpanded .chatSummaryThoughtTranslate, +.chatSummaryThoughtTranslate:focus-visible, +.chatSummaryThoughtTranslate[data-state='open'] { + opacity: 1; + pointer-events: auto; +} + +/* The expanded-state chevron stays visible, like the thinking block. */ +.chatSummaryThoughtChevronDown { + opacity: 1; +} + +.chatSummaryThoughtChevronRight::before, +.chatSummaryThoughtChevronRight::after, +.chatSummaryThoughtChevronDown::before, +.chatSummaryThoughtChevronDown::after { + content: ''; + position: absolute; + width: 6px; + height: 1px; + background: currentColor; + border-radius: 1px; +} + +.chatSummaryThoughtChevronRight::before { + top: calc(50% - 2px); + left: calc(50% - 3px); + transform: rotate(45deg); +} + +.chatSummaryThoughtChevronRight::after { + top: calc(50% + 2px); + left: calc(50% - 3px); + transform: rotate(-45deg); +} + +.chatSummaryThoughtChevronDown::before { + top: 50%; + left: calc(50% - 5px); + transform: rotate(45deg); +} + +.chatSummaryThoughtChevronDown::after { + top: 50%; + left: calc(50% - 1px); + transform: rotate(-45deg); +} + +.chatSummaryThoughtContent { + margin-top: 4px; + padding-left: 21px; + color: var(--muted-foreground); + font-size: 13px; + line-height: 1.5; + opacity: 0.9; +} + +.chatSummaryThoughtContent p, +.chatSummaryThoughtContent ul, +.chatSummaryThoughtContent ol { + margin-top: 0; + margin-bottom: 4px; +} + +.chatSummaryThoughtContent :last-child { + margin-bottom: 0; +} + .line { padding: 2px 0; min-width: 0; diff --git a/packages/web-shell/client/e2e/web-shell.compact-thinking.spec.ts b/packages/web-shell/client/e2e/web-shell.compact-thinking.spec.ts new file mode 100644 index 0000000000..59972db0b7 --- /dev/null +++ b/packages/web-shell/client/e2e/web-shell.compact-thinking.spec.ts @@ -0,0 +1,103 @@ +import { expect, test, type Page, type TestInfo } from '@playwright/test'; +import { + assistantTextEvent, + createWebShellDaemonScenario, + installMockDaemon, + replayCompleteEvent, + type DaemonEvent, + type MockDaemonController, + type WebShellDaemonScenario, +} from './utils/mockDaemon'; + +function thoughtTextEvent(text: string): DaemonEvent { + return { + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text }, + }, + }, + }; +} + +test('compact mode keeps the thinking block visible while streaming', async ({ + page, +}, testInfo) => { + const scenario = createWebShellDaemonScenario({ + settings: { + settings: [ + { + key: 'ui.compactMode', + type: 'boolean', + label: 'Compact Mode', + category: 'UI', + requiresRestart: false, + default: false, + values: { effective: true, workspace: true, user: false }, + }, + ], + }, + }); + const daemon = await installScenario(page, scenario, testInfo); + + await gotoSession(page, scenario, daemon); + + await fillComposer(page, 'Ping from tmp compact test'); + await page.locator('[data-web-shell-composer-submit]').click(); + await expect.poll(() => daemon.promptRequests().length).toBe(1); + + // The thinking phase shows the thinking block as its own row; the thought + // content stays collapsed and the top collapse row keeps its generic live + // label. + await daemon.sse.split( + thoughtTextEvent('private chain of thought about the weather'), + ); + const list = page.locator('[data-web-shell-message-list]'); + await expect(list).toContainText('Thinking', { timeout: 5000 }); + await expect(list).toContainText('Processing'); + await expect(list).not.toContainText('private chain of thought'); + + // Thinking ends once the assistant starts answering: the block's live label + // flips to its completed summary while the collapse row stays. + await daemon.sse.split(assistantTextEvent('the weather is rainy')); + await expect(list).not.toContainText('Thinking', { timeout: 5000 }); + await expect(list).toContainText('Processing'); +}); + +async function installScenario( + page: Page, + scenario: WebShellDaemonScenario, + testInfo: TestInfo, +): Promise { + return installMockDaemon(page, scenario, { + baseURL: String(testInfo.project.use.baseURL), + }); +} + +async function gotoSession( + page: Page, + scenario: WebShellDaemonScenario, + daemon: MockDaemonController, +): Promise { + await page.goto(`/session/${encodeURIComponent(scenario.sessionId)}`); + await expect(page.locator('[data-web-shell-root]')).toBeVisible(); + const connection = await daemon.sse.waitForConnection(scenario.sessionId); + await daemon.sendEvent( + replayCompleteEvent({ + sessionId: connection.sessionId, + replayedCount: scenario.events.length, + }), + ); + await expect(page.getByText('Loading...')).toHaveCount(0); +} + +async function fillComposer(page: Page, text: string): Promise { + const editor = page.locator('[data-web-shell-composer-editor] .cm-content'); + await editor.click(); + await page.keyboard.press( + process.platform === 'darwin' ? 'Meta+A' : 'Control+A', + ); + await page.keyboard.type(text); +}