From d4d04ef0fbb9f4b0ee7a1b98ba33df318c0c1009 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sun, 14 Jun 2026 06:44:54 +0800 Subject: [PATCH 1/4] feat(web-shell): reveal full tool detail and auto-collapse finished tools Long tool descriptions were hard-capped at 120 characters and finished tools (shell/edit/write) stayed expanded indefinitely, so commands were unreadable and the transcript filled with stale output. - Lift the 120-char description cap so the full command/path reaches the DOM; collapsed rows ellipsise via CSS (adapts to width) and a click reflows the full text into a wrapped block below the header. - Add a leading disclosure chevron; any row with detail output or a long description is now expandable. - Auto-collapse a tool to its one-line summary once it completes successfully. Running tools stay expanded (live output) and failures stay expanded (error visible); agents keep their own manual expand state. --- .../components/messages/ToolGroup.test.tsx | 156 +++++++++++++++++- .../client/components/messages/ToolGroup.tsx | 51 +++++- .../messages/toolFormatting.test.ts | 20 +++ .../components/messages/toolFormatting.ts | 13 +- .../messages/tools/ToolChrome.module.css | 27 +++ 5 files changed, 258 insertions(+), 9 deletions(-) diff --git a/packages/web-shell/client/components/messages/ToolGroup.test.tsx b/packages/web-shell/client/components/messages/ToolGroup.test.tsx index 177b55060aa..a9e4c537c2a 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.test.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.test.tsx @@ -27,23 +27,26 @@ afterEach(() => { } }); -function makeShellTool(output: string): ACPToolCall { +function makeShellTool( + output: string, + status: ACPToolCall['status'] = 'completed', +): ACPToolCall { return { callId: 'call-shell-1', toolName: 'Shell', - status: 'completed', + status, rawOutput: { output }, }; } -function renderShellTool(output: string): HTMLElement { +function renderTool(tool: ACPToolCall): HTMLElement { const container = document.createElement('div'); document.body.appendChild(container); const root = createRoot(container); act(() => { root.render( - + , ); }); @@ -51,6 +54,44 @@ function renderShellTool(output: string): HTMLElement { return container; } +function renderShellTool(output: string): HTMLElement { + const container = renderTool(makeShellTool(output)); + // Completed tools collapse to a one-line summary by default; open the row so + // the assertions below can inspect the bash-output view. + const chevron = [...container.querySelectorAll('span')].find( + (s) => s.textContent === '▸', + ); + if (chevron?.parentElement) click(chevron.parentElement); + return container; +} + +function makeShellCommandTool(command: string): ACPToolCall { + return { + callId: 'call-shell-cmd', + toolName: 'run_shell_command', + status: 'completed', + args: { command }, + rawOutput: { output: 'done' }, + }; +} + +function makeAgentTool(status: ACPToolCall['status']): ACPToolCall { + return { + callId: 'agent-1', + toolName: 'task', + status, + args: { description: 'agentDescMarker' }, + subTools: [ + { + callId: 'agent-1-sub-1', + toolName: 'Read', + status: 'completed', + args: { file_path: '/ws/SubToolMarker.ts' }, + }, + ], + }; +} + function getExpandButton(container: HTMLElement): HTMLButtonElement { const button = container.querySelector('button'); expect(button).not.toBeNull(); @@ -124,3 +165,110 @@ describe('shell tool output expand toggle', () => { expect(button.getAttribute('aria-expanded')).toBe('false'); }); }); + +describe('tool description expand toggle', () => { + it('keeps a finished command collapsed but reveals it in full on expand', () => { + const command = `npm run build && npm run test && npm run lint -- ${'x'.repeat( + 80, + )}`; + const container = renderTool(makeShellCommandTool(command)); + + // Completed → collapsed: chevron points right. The full command (no longer + // hard-capped at one line's worth of characters) is present in the header. + expect(container.textContent).toContain('▸'); + expect(container.textContent).toContain(command); + + const chevron = [...container.querySelectorAll('span')].find( + (s) => s.textContent === '▸', + ); + click(chevron!.parentElement!); + + // Expanded → chevron flips and the full command is reflowed below. + expect(container.textContent).toContain('▾'); + expect(container.textContent).toContain(command); + }); +}); + +describe('auto-collapse on finish', () => { + it('collapses a completed tool to its summary by default', () => { + const container = renderTool(makeShellTool('a\nb\nc\nd')); + // The expanded bash
 is not rendered until the user opens the row.
+    expect(container.querySelector('pre')).toBeNull();
+    expect(container.textContent).toContain('▸');
+  });
+
+  it('keeps a running tool expanded so streaming output stays visible', () => {
+    const container = renderTool(
+      makeShellTool('streaming output', 'in_progress'),
+    );
+    expect(container.querySelector('pre')?.textContent).toContain('streaming');
+  });
+
+  it('keeps a failed tool expanded so the error stays visible', () => {
+    const container = renderTool(
+      makeShellTool('error: boom\n  at step 1', 'failed'),
+    );
+    expect(container.querySelector('pre')?.textContent).toContain('boom');
+  });
+
+  it('auto-collapses a running tool when it transitions to completed', () => {
+    const container = document.createElement('div');
+    document.body.appendChild(container);
+    const root = createRoot(container);
+    mounted.push({ root, container });
+
+    const output = 'line1\nline2\nline3\nline4';
+    act(() => {
+      root.render(
+        
+          
+        ,
+      );
+    });
+    // Running → expanded: the bash output is visible.
+    expect(container.querySelector('pre')).not.toBeNull();
+
+    // Same callId, now finished → the row collapses on its own.
+    act(() => {
+      root.render(
+        
+          
+        ,
+      );
+    });
+    expect(container.querySelector('pre')).toBeNull();
+  });
+
+  it('does not collapse an agent the user expanded when it completes', () => {
+    const container = document.createElement('div');
+    document.body.appendChild(container);
+    const root = createRoot(container);
+    mounted.push({ root, container });
+
+    const render = (status: ACPToolCall['status']) =>
+      act(() => {
+        root.render(
+          
+            
+          ,
+        );
+      });
+
+    render('in_progress');
+    // Agents start collapsed: the sub-tool panel is hidden.
+    expect(container.textContent).not.toContain('SubToolMarker');
+
+    // Expand by clicking the agent's summary row.
+    const summaryLabel = [...container.querySelectorAll('span')].find(
+      (s) => s.textContent === 'task:',
+    );
+    expect(summaryLabel).toBeTruthy();
+    click(summaryLabel!.parentElement!);
+    expect(container.textContent).toContain('SubToolMarker');
+
+    // Completion must NOT yank the panel shut: the collapse-on-finish effect
+    // is scoped to non-agent tools.
+    render('completed');
+    expect(container.textContent).toContain('SubToolMarker');
+  });
+});
diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx
index 7964eedd7ab..114e1e92d6d 100644
--- a/packages/web-shell/client/components/messages/ToolGroup.tsx
+++ b/packages/web-shell/client/components/messages/ToolGroup.tsx
@@ -154,6 +154,10 @@ function buildUnifiedDiff(oldText: string, newText: string): string {
 const MAX_BASH_LINE_CHARS = 150;
 const MAX_READ_LINES = 25;
 
+// A description longer than this is likely ellipsised on a normal-width row, so
+// the row becomes expandable to re-flow the full text into a wrapped block.
+const DESCRIPTION_EXPAND_THRESHOLD = 60;
+
 export function resolveShellOutputMaxLines(
   settings: readonly DaemonSettingDescriptor[],
 ): number {
@@ -411,6 +415,10 @@ function getAgentDisplayInfo(
 }
 
 function shouldAutoExpand(tool: ACPToolCall): boolean {
+  // Once a tool completes successfully, collapse it to its one-line summary so
+  // the transcript stays scannable; the user can click to reopen it. Failures
+  // stay expanded so the error output remains visible without an extra click.
+  if (tool.status === 'completed') return false;
   const name = tool.toolName.toLowerCase();
   if (isAskUserQuestionToolName(tool.toolName)) return true;
   if (name === 'write_file' || name === 'writefile') return true;
@@ -465,6 +473,27 @@ function ToolHeaderExtra({ info }: { info: ToolHeaderExtraRenderInfo }) {
   );
 }
 
+function isDescriptionExpandable(description: string): boolean {
+  return (
+    description.length > DESCRIPTION_EXPAND_THRESHOLD ||
+    description.includes('\n')
+  );
+}
+
+function ToggleChevron({
+  expandable,
+  expanded,
+}: {
+  expandable: boolean;
+  expanded: boolean;
+}) {
+  return (
+    
+  );
+}
+
 function getActiveTool(tools: ACPToolCall[]): ACPToolCall {
   return (
     tools.find((t) => t.status === 'in_progress') ?? tools[tools.length - 1]
@@ -611,6 +640,14 @@ export const ToolLine = memo(function ToolLine({
     return () => clearInterval(id);
   }, [isRunningAgent]);
 
+  // Collapse a regular tool to its one-line summary once it completes
+  // successfully. Agents are excluded — they keep whatever expand state the
+  // user chose, driven from their own panel — and failures stay open so the
+  // error output remains visible.
+  useEffect(() => {
+    if (!isAgent && tool.status === 'completed') setExpanded(false);
+  }, [isAgent, tool.status]);
+
   if (isAgent) {
     if (hasApproval && onConfirm) {
       return (
@@ -712,10 +749,16 @@ export const ToolLine = memo(function ToolLine({
     isShellToolName(tool.toolName) || isWebFetchToolName(tool.toolName)
       ? ''
       : formatElapsed(tool.startTime, tool.endTime);
-  const expandable = hasExpandableContent(tool);
 
   const name = tool.toolName.toLowerCase();
   const isTodo = name === 'todowrite';
+  // A row expands when it has detail output (bash/diff/read content) or when
+  // its description is long enough to be ellipsised. When a long description is
+  // expanded we move it out of the header into a wrapped block below, so the
+  // header drops its single-line copy.
+  const descExpandable = !isTodo && isDescriptionExpandable(description);
+  const expandable = !isTodo && (hasExpandableContent(tool) || descExpandable);
+  const relocateDescription = expanded && descExpandable;
 
   if (hasApproval && onConfirm) {
     return (
@@ -731,6 +774,7 @@ export const ToolLine = memo(function ToolLine({
         className={`${styles.lineMain} ${expandable ? styles.lineExpandable : ''}`}
         onClick={expandable ? () => setExpanded(!expanded) : undefined}
       >
+        
         
         {displayName}
         
       
       {isTodo && }
+      {relocateDescription && (
+        
{description}
+ )} {!isTodo && !expanded && result && (
{result}
)} diff --git a/packages/web-shell/client/components/messages/toolFormatting.test.ts b/packages/web-shell/client/components/messages/toolFormatting.test.ts index 4fddd045e41..a2a1b3ee6fd 100644 --- a/packages/web-shell/client/components/messages/toolFormatting.test.ts +++ b/packages/web-shell/client/components/messages/toolFormatting.test.ts @@ -170,4 +170,24 @@ describe('toolFormatting', () => { ), ).toBe('3 line(s)'); }); + + it('keeps long shell commands in full instead of capping at one line', () => { + const command = `echo ${'a'.repeat(200)}`; + expect( + getToolDescription( + tool({ toolName: 'run_shell_command', args: { command } }), + ), + ).toBe(command); + }); + + it('still bounds a pathologically long description', () => { + const result = getToolDescription( + tool({ + toolName: 'run_shell_command', + args: { command: 'x'.repeat(5000) }, + }), + ); + expect(result.length).toBeLessThan(5000); + expect(result.endsWith('...')).toBe(true); + }); }); diff --git a/packages/web-shell/client/components/messages/toolFormatting.ts b/packages/web-shell/client/components/messages/toolFormatting.ts index 652af52a50b..e18d956db8e 100644 --- a/packages/web-shell/client/components/messages/toolFormatting.ts +++ b/packages/web-shell/client/components/messages/toolFormatting.ts @@ -49,14 +49,21 @@ export function truncateText(text: string, max: number): string { return text.slice(0, max) + '...'; } +// The tool-header description is shown single-line (CSS-ellipsised) when the +// row is collapsed and fully wrapped when it is expanded, so we keep the whole +// string rather than hard-capping it at a line's worth of characters. A +// generous ceiling still guards against a pathological multi-megabyte command +// bloating the DOM. +const MAX_DESCRIPTION_LENGTH = 2000; + export function getToolDescription( tool: ACPToolCall, workspaceCwd?: string, ): string { const fromTitle = getDescriptionFromTitle(tool, workspaceCwd); - if (fromTitle) return truncateText(fromTitle, 120); + if (fromTitle) return truncateText(fromTitle, MAX_DESCRIPTION_LENGTH); const fromArgs = getDescriptionFromArgs(tool, workspaceCwd); - if (fromArgs) return truncateText(fromArgs, 120); + if (fromArgs) return truncateText(fromArgs, MAX_DESCRIPTION_LENGTH); return ''; } @@ -177,7 +184,7 @@ function getDescriptionFromArgs( if (args.description) { description += ` (${String(args.description).replace(/\n/g, ' ')})`; } - return truncateText(description, 120); + return truncateText(description, MAX_DESCRIPTION_LENGTH); } if (name === 'grep_search' || name === 'grep' || name === 'search') { const pattern = args.pattern ?? args.query; 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 ac4dd481953..02f123e5369 100644 --- a/packages/web-shell/client/components/messages/tools/ToolChrome.module.css +++ b/packages/web-shell/client/components/messages/tools/ToolChrome.module.css @@ -24,6 +24,18 @@ min-width: 0; } +/* Leading disclosure column. Rendered (blank) for every tool row so names stay + aligned; shows ▸/▾ only when the row can expand. */ +.lineToggle { + width: 12px; + flex-shrink: 0; + font-size: 10px; + line-height: 1; + color: var(--text-dimmed); + text-align: center; + user-select: none; +} + .icon { font-size: 13px; flex-shrink: 0; @@ -110,6 +122,21 @@ line-height: 1.4; } +/* Full, wrapped tool argument shown below the header when the row is expanded + (the header drops its single-line ellipsised copy). Aligns with the expanded + output block below it. */ +.lineFullArg { + margin-left: 16px; + margin-top: 2px; + font-size: 12px; + line-height: 1.4; + color: var(--text-secondary); + font-family: var(--font-mono); + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: anywhere; +} + .lineDetail { } From db1970b6df2317e6a74062a5f6a2549f4bf6a36b Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sun, 14 Jun 2026 08:42:19 +0800 Subject: [PATCH 2/4] fix(web-shell): preserve manual expand on completion; correct auto-expand comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #5088: - shouldAutoExpand: rewrite the comment to match the code. Only the verbose kinds (shell/edit/write/ask) auto-expand and stay expanded on failure; other kinds are collapsed by default (their summary line shows the outcome and they stay click-to-expand). Force-expanding every failed tool was rejected because tools without an expanded-detail renderer would then hide the summary line and show an empty body — i.e. hide the error. - Auto-collapse-on-completion no longer overrides an explicit user toggle: a userToggledRef latch (set on header click, reset on tool-identity change) guards the collapse effect, so a row the user expanded/collapsed keeps its state when the tool finishes. --- .../components/messages/ToolGroup.test.tsx | 42 +++++++++++++++++++ .../client/components/messages/ToolGroup.tsx | 35 ++++++++++++---- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/packages/web-shell/client/components/messages/ToolGroup.test.tsx b/packages/web-shell/client/components/messages/ToolGroup.test.tsx index a9e4c537c2a..b6832d23000 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.test.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.test.tsx @@ -271,4 +271,46 @@ describe('auto-collapse on finish', () => { render('completed'); expect(container.textContent).toContain('SubToolMarker'); }); + + it('keeps a tool the user manually expanded open when it completes', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + // Long command → the row is expandable/clickable even while running. + const command = `echo ${'z'.repeat(80)}`; + const renderStatus = (status: ACPToolCall['status']) => + act(() => { + root.render( + + + , + ); + }); + + renderStatus('in_progress'); + const chevron = () => + [...container.querySelectorAll('span')].find( + (s) => s.textContent === '▾' || s.textContent === '▸', + ); + // Toggle twice → marks the row user-controlled, ending in the expanded state. + click(chevron()!.parentElement!); + click(chevron()!.parentElement!); + expect(container.textContent).toContain('▾'); + + // Completion must NOT override the user's explicit expand. + renderStatus('completed'); + expect(container.textContent).toContain('▾'); + }); }); diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index 114e1e92d6d..ad3710a2adf 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -1,4 +1,4 @@ -import { memo, useContext, useEffect, useMemo, useState } from 'react'; +import { memo, useContext, useEffect, useMemo, useRef, useState } from 'react'; import type { DaemonSettingDescriptor } from '@qwen-code/webui/daemon-react-sdk'; import type { ACPToolCall, @@ -415,9 +415,13 @@ function getAgentDisplayInfo( } function shouldAutoExpand(tool: ACPToolCall): boolean { - // Once a tool completes successfully, collapse it to its one-line summary so - // the transcript stays scannable; the user can click to reopen it. Failures - // stay expanded so the error output remains visible without an extra click. + // Only the verbose tool kinds below (shell/edit/write/ask) auto-expand, and + // only while pending/in-progress or after failing: a successful completion + // collapses them to a one-line summary so the transcript stays scannable + // (click to reopen), while a failure of those kinds stays expanded so its + // error output is visible without a click. Every other tool kind is collapsed + // by default regardless of status — its summary line already shows the + // outcome and it stays click-to-expand. if (tool.status === 'completed') return false; const name = tool.toolName.toLowerCase(); if (isAskUserQuestionToolName(tool.toolName)) return true; @@ -615,11 +619,16 @@ export const ToolLine = memo(function ToolLine({ const [expanded, setExpanded] = useState( () => !compactMode && shouldAutoExpand(tool), ); + // Set once the user explicitly toggles this row, so auto-collapse-on- + // completion never silently overrides their choice. + const userToggledRef = useRef(false); const [now, setNow] = useState(() => Date.now()); useEffect( () => { setExpanded(compactMode ? false : shouldAutoExpand(tool)); + // A new tool identity (or compact-mode toggle) resets the manual latch. + userToggledRef.current = false; }, // eslint-disable-next-line react-hooks/exhaustive-deps [compactMode, tool.callId, tool.toolName], @@ -641,11 +650,14 @@ export const ToolLine = memo(function ToolLine({ }, [isRunningAgent]); // Collapse a regular tool to its one-line summary once it completes - // successfully. Agents are excluded — they keep whatever expand state the - // user chose, driven from their own panel — and failures stay open so the + // successfully — unless the user explicitly toggled this row, in which case + // their choice wins. Agents are excluded (they keep whatever expand state the + // user chose, driven from their own panel) and failures stay open so the // error output remains visible. useEffect(() => { - if (!isAgent && tool.status === 'completed') setExpanded(false); + if (!isAgent && tool.status === 'completed' && !userToggledRef.current) { + setExpanded(false); + } }, [isAgent, tool.status]); if (isAgent) { @@ -772,7 +784,14 @@ export const ToolLine = memo(function ToolLine({
setExpanded(!expanded) : undefined} + onClick={ + expandable + ? () => { + userToggledRef.current = true; + setExpanded((value) => !value); + } + : undefined + } > From 4ff3e03649671060a797ecd81416c032943052a8 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sun, 14 Jun 2026 08:48:17 +0800 Subject: [PATCH 3/4] test(web-shell): assert tool-detail relocation via DOM, not textContent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback (#5088): the expand test asserted container.textContent contains the command before and after the click, which passes regardless of whether the description is relocated from the header span to the wrapped block (textContent concatenates the whole subtree). Assert the DOM move instead — the command is in a leaf while collapsed and in none while expanded — so a regression dropping the relocation now fails the test. --- .../components/messages/ToolGroup.test.tsx | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/web-shell/client/components/messages/ToolGroup.test.tsx b/packages/web-shell/client/components/messages/ToolGroup.test.tsx index b6832d23000..f4790d8c469 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.test.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.test.tsx @@ -167,25 +167,33 @@ describe('shell tool output expand toggle', () => { }); describe('tool description expand toggle', () => { - it('keeps a finished command collapsed but reveals it in full on expand', () => { + it('relocates the full command from the header into a wrapped block on expand', () => { const command = `npm run build && npm run test && npm run lint -- ${'x'.repeat( 80, )}`; const container = renderTool(makeShellCommandTool(command)); - // Completed → collapsed: chevron points right. The full command (no longer - // hard-capped at one line's worth of characters) is present in the header. + // textContent alone can't prove relocation (it concatenates the whole + // subtree, so the command is present in either state). Assert the DOM move + // instead: collapsed, the full command lives in the header's single-line + // arg (CSS-ellipsised); expanded, it is no longer in any leaf + // but reflowed into the wrapped block below. + const commandInLeafSpan = () => + [...container.querySelectorAll('span')].some( + (s) => s.textContent === command, + ); + expect(container.textContent).toContain('▸'); - expect(container.textContent).toContain(command); + expect(commandInLeafSpan()).toBe(true); const chevron = [...container.querySelectorAll('span')].find( (s) => s.textContent === '▸', ); click(chevron!.parentElement!); - // Expanded → chevron flips and the full command is reflowed below. expect(container.textContent).toContain('▾'); - expect(container.textContent).toContain(command); + expect(commandInLeafSpan()).toBe(false); + expect(container.textContent).toContain(command); // still present, in the block }); }); From 35e6d8b5142a2596d0eaa13e6baf8fe48bbcd91b Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sun, 14 Jun 2026 09:30:25 +0800 Subject: [PATCH 4/4] fix(web-shell): keep result summary when expanding a detail-less tool Review feedback (#5088): a tool that is description-expandable but has no kind-specific detail renderer (grep/glob/web_fetch with a long description) used to lose its one-line result summary on expand and render an empty detail area instead. Gate both on a new hasDetailView(tool): the summary now stays visible (shown when collapsed OR when the expanded row has no detail view), and the empty detail div is no longer rendered. Description relocation is kept independent of detail content so long shell commands with short/no output remain click-to-reflow. --- .../components/messages/ToolGroup.test.tsx | 25 ++++++++++++++++ .../client/components/messages/ToolGroup.tsx | 29 +++++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/web-shell/client/components/messages/ToolGroup.test.tsx b/packages/web-shell/client/components/messages/ToolGroup.test.tsx index f4790d8c469..7bd0c1214d3 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.test.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.test.tsx @@ -195,6 +195,31 @@ describe('tool description expand toggle', () => { expect(commandInLeafSpan()).toBe(false); expect(container.textContent).toContain(command); // still present, in the block }); + + it('keeps the result summary when expanding a long-description tool with no detail view', () => { + // glob with a long pattern: descExpandable but no kind-specific renderer. + const pattern = `**/${'x'.repeat(80)}/*.ts`; + const container = renderTool({ + callId: 'call-glob', + toolName: 'glob', + status: 'completed', + args: { pattern }, + rawOutput: 'a.ts\nb.ts\nc.ts', + }); + + const chevron = [...container.querySelectorAll('span')].find( + (s) => s.textContent === '▸', + ); + expect(chevron).toBeTruthy(); // long pattern → expandable + expect(container.textContent).toContain('matching file'); // summary, collapsed + + click(chevron!.parentElement!); + + // Expanded: the summary must NOT be lost (no detail view replaces it), and + // the full pattern is reflowed into the block. + expect(container.textContent).toContain('matching file'); + expect(container.textContent).toContain(pattern); + }); }); describe('auto-collapse on finish', () => { diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index ad3710a2adf..2b00a16367e 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -81,6 +81,27 @@ function hasExpandableContent(tool: ACPToolCall): boolean { return false; } +// Tools whose expanded row renders a kind-specific detail view (shell output / +// diff / file content / Q&A). Must stay in sync with the renderers in +// ToolLine's lineDetail block below. Tools NOT in this set have nothing extra +// to show when expanded, so they keep their one-line result summary instead of +// hiding it behind an empty detail area. +function hasDetailView(tool: ACPToolCall): boolean { + const name = tool.toolName.toLowerCase(); + return ( + isShellToolName(name) || + name === 'write_file' || + name === 'writefile' || + name === 'edit' || + name === 'write' || + name === 'editfile' || + name === 'read' || + name === 'read_file' || + name === 'readfile' || + isAskUserQuestionToolName(tool.toolName) + ); +} + function hasDiffContent(tool: ACPToolCall): boolean { if (tool.content?.some((b) => b.type === 'diff')) return true; if (tool.rawOutput && typeof tool.rawOutput === 'object') { @@ -771,6 +792,10 @@ export const ToolLine = memo(function ToolLine({ const descExpandable = !isTodo && isDescriptionExpandable(description); const expandable = !isTodo && (hasExpandableContent(tool) || descExpandable); const relocateDescription = expanded && descExpandable; + // Whether the expanded row renders a kind-specific detail view. When it does + // not (e.g. grep/glob/web_fetch with a long description), keep the result + // summary visible instead of replacing it with an empty detail area. + const detailView = hasDetailView(tool); if (hasApproval && onConfirm) { return ( @@ -811,10 +836,10 @@ export const ToolLine = memo(function ToolLine({ {relocateDescription && (
{description}
)} - {!isTodo && !expanded && result && ( + {!isTodo && result && (!expanded || !detailView) && (
{result}
)} - {!isTodo && expanded && ( + {!isTodo && expanded && detailView && (
{isShellToolName(name) && (