From 933a5203cb2fbef8fbb096ce6f5b038310053fc0 Mon Sep 17 00:00:00 2001 From: ytahdn <1294726970@qq.com> Date: Fri, 14 Aug 2026 02:15:08 +0000 Subject: [PATCH] fix(web-shell): soften tool execution failure hints (#9053) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web-shell): soften tool execution failure hints Tool failures no longer advertise themselves as text in the collapsed tool-group summaries: the "Failed"/"执行失败" label is replaced with a small error icon, the collapsed summary (single tool or aggregated) drops the error marker entirely, and parallel agent summaries append a plain-text failed count after the done counter. A failed single tool shows the error icon in its expanded card title. The failure is still discoverable inside the expanded view via the icon and the tool output itself. * test(web-shell): lock error-icon rendering and harden card title overflow * fix(web-shell): keep failure evidence visible in icon-only error display Address review feedback on the weakened tool error display: swap the hand-rolled circle-X SVG for lucide's CircleXIcon, move the parallel agents failed count ahead of the done counter so it survives the summary tail truncation, and restore the error icon for shapes the label-to-icon conversion left without any failure indicator (single read tools and failed tools without result text). Lock each behavior, including the cancelled-agent count, with focused tests. * test(web-shell): pin failure icon accessible name and completed-card icon absence (#9053) Co-authored-by: Qwen-Coder --------- Co-authored-by: 钉萁 Co-authored-by: qwen-code-dev-bot Co-authored-by: Qwen-Coder --- .../components/messages/ToolGroup.test.tsx | 118 +++++++++++++++++- .../client/components/messages/ToolGroup.tsx | 34 +++-- .../tools/ParallelAgentsGroup.module.css | 7 -- .../tools/ParallelAgentsGroup.test.tsx | 67 ++++++++++ .../messages/tools/ParallelAgentsGroup.tsx | 37 +++--- .../messages/tools/SubAgentPanel.test.tsx | 15 +++ .../messages/tools/ToolChrome.module.css | 10 ++ .../components/messages/tools/toolDisplay.tsx | 10 +- packages/web-shell/client/i18n.tsx | 2 + 9 files changed, 258 insertions(+), 42 deletions(-) diff --git a/packages/web-shell/client/components/messages/ToolGroup.test.tsx b/packages/web-shell/client/components/messages/ToolGroup.test.tsx index f1548657e2..91e9f9f1f4 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.test.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.test.tsx @@ -776,12 +776,122 @@ describe('tool row rendering', () => { } }); - it('shows failed status in the collapsed chat summary', () => { + it('keeps the failed label out of the collapsed chat summary', () => { const container = renderToolGroup([ makeTool({ toolName: 'Shell', status: 'failed' }), ]); - expect(container.querySelector('button')?.textContent).toContain('Failed'); + const summary = container.querySelector('button'); + expect(summary?.textContent).toContain('Shell'); + expect(summary?.textContent).not.toContain('Failed'); + expect(summary?.querySelector('[class*="iconError"]')).toBeNull(); + }); + + it('shows the error icon in a failed tool line header', () => { + const container = renderToolLine( + makeTool({ toolName: 'Shell', status: 'failed' }), + ); + + const errorIcon = container.querySelector('[class*="iconError"]'); + expect(errorIcon).not.toBeNull(); + expect(errorIcon?.getAttribute('role')).toBe('img'); + expect(errorIcon?.getAttribute('aria-label')).toBe('Failed'); + expect(errorIcon?.querySelector('svg')).not.toBeNull(); + expect(container.textContent).not.toContain('Failed'); + }); + + it('shows an error icon instead of the failed label on expanded tool rows', () => { + const container = renderToolGroup([ + makeTool({ + toolName: 'Shell', + status: 'failed', + content: [{ type: 'content', content: { text: 'boom' } }], + }), + makeTool({ callId: 'call-2', toolName: 'Grep', status: 'completed' }), + ]); + + const summary = container.querySelector('button') as HTMLButtonElement; + act(() => summary.click()); + + const errorIcon = container.querySelector('[class*="iconError"]'); + expect(errorIcon).not.toBeNull(); + expect(errorIcon?.querySelector('svg')).not.toBeNull(); + expect(errorIcon?.textContent).not.toContain('Failed'); + }); + + it('shows an error icon in the expanded single-tool card title', () => { + const container = renderToolGroup([ + makeTool({ + toolName: 'Shell', + status: 'failed', + content: [{ type: 'content', content: { text: 'boom' } }], + }), + ]); + + const summary = container.querySelector('button') as HTMLButtonElement; + act(() => summary.click()); + + const titleRow = container.querySelector('[class*="expandedCardTitleRow"]'); + expect(titleRow).not.toBeNull(); + expect(titleRow?.querySelector('[class*="iconError"] svg')).not.toBeNull(); + expect(titleRow?.textContent).not.toContain('Failed'); + }); + + it('renders no status icon in the expanded completed tool card title', () => { + const container = renderToolGroup([ + makeTool({ + toolName: 'Shell', + status: 'completed', + content: [{ type: 'content', content: { text: 'ok' } }], + }), + ]); + + const summary = container.querySelector('button') as HTMLButtonElement; + act(() => summary.click()); + + const titleRow = container.querySelector('[class*="expandedCardTitleRow"]'); + expect(titleRow).not.toBeNull(); + expect(titleRow?.querySelector('[class*="iconError"]')).toBeNull(); + }); + + it('shows an error icon in the expanded failed todo card title', () => { + const container = renderToolGroup([ + makeTool({ + toolName: 'todo_write', + status: 'failed', + args: { + todos: [{ id: '1', content: 'Check UI', status: 'in_progress' }], + }, + }), + ]); + + const titleRow = container.querySelector('[class*="expandedCardTitleRow"]'); + expect(titleRow).not.toBeNull(); + expect(titleRow?.querySelector('[class*="iconError"] svg')).not.toBeNull(); + }); + + it('shows an error icon for a single failed read tool', () => { + const container = renderToolGroup([ + makeTool({ + toolName: 'read_file', + status: 'failed', + content: [{ type: 'content', content: { text: 'Permission denied' } }], + }), + ]); + + const titleRow = container.querySelector('[class*="expandedCardTitleRow"]'); + expect(titleRow).not.toBeNull(); + expect(titleRow?.querySelector('[class*="iconError"] svg')).not.toBeNull(); + }); + + it('shows an error icon for a single failed tool without result text', () => { + const container = renderToolGroup([ + makeTool({ toolName: 'glob', status: 'failed' }), + ]); + + const titleRow = container.querySelector('[class*="expandedCardTitleRow"]'); + expect(titleRow).not.toBeNull(); + expect(titleRow?.querySelector('[class*="iconError"] svg')).not.toBeNull(); }); it('renders ANSI shell output as styled spans instead of escape text', () => { @@ -1353,7 +1463,9 @@ describe('tool row rendering', () => { act(() => header.click()); - const cardTitle = container.querySelector('[class*="expandedCardTitle"]'); + const cardTitle = container.querySelector( + '[class*="expandedCardTitleRow"] [class*="expandedCardTitle"]', + ); expect(cardTitle?.textContent).toBe('Shell'); }); diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index 8fd2c45736..42155fef71 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -348,16 +348,21 @@ function ExpandedEditContent({ tool }: { tool: ACPToolCall }) { function ToolExpandedCard({ title, detail, + status, children, }: { title: string; detail?: string; + status?: ACPToolCall['status']; children?: ReactNode; }) { return (
- {title} + + {status && } + {title} + {detail && {detail}}
{children &&
{children}
} @@ -397,7 +402,7 @@ function TodoToolBody({ const timeline = useContext(TodoTimelineContext); const events = timeline.get(tool.callId)?.events ?? []; return expanded ? ( - +
@@ -1301,8 +1306,13 @@ export const ToolLine = memo(function ToolLine({ const hideDescriptionInHeader = showDescriptionInDetail && !isShell && !isSearch && !isRead; const expandedCardDetail = fullDescription; + // A failed tool with no result text still gets the titled card so its + // title-row error icon remains visible when expanded. const showExpandedSummaryPanel = - !isTodo && expanded && !detailView && (showDescriptionInDetail || result); + !isTodo && + expanded && + !detailView && + (showDescriptionInDetail || result || tool.status === 'failed'); return (
@@ -1413,7 +1423,11 @@ export const ToolLine = memo(function ToolLine({
)} {showExpandedSummaryPanel && ( - + {result && (
{isRead ? ( - + + + ) : ( - + {isShellToolName(name) && } {(name === 'write_file' || name === 'writefile') && ( @@ -1485,7 +1505,6 @@ export const ToolGroup = memo(function ToolGroup({ const [chatExpanded, setChatExpanded] = useState(false); const monitorDetailsRequestRef = useRef(null); const hasRunningTool = hasActiveAgents(tools); - const hasFailedTool = tools.some((tool) => tool.status === 'failed'); const activeTool = tools.find( (tool) => @@ -1563,7 +1582,6 @@ export const ToolGroup = memo(function ToolGroup({ )} - {hasFailedTool && } { } }); + it('shows a failed count in the collapsed summary', () => { + const container = renderExpandedGroup([ + agent({ callId: 'done', status: 'completed' }), + agent({ callId: 'failed', status: 'failed' }), + ]); + + expect(container.textContent).toContain('2/2 done'); + expect(container.textContent).toContain('1 failed'); + expect(container.textContent).not.toContain('Failed'); + expect( + groupSummary(container).querySelector('[class*="iconError"]'), + ).toBeNull(); + // The failed count must precede the done counter: summaryText truncates + // from the tail, so this order keeps failure evidence visible when the + // row is narrow. + const summaryText = groupSummary(container).textContent ?? ''; + expect(summaryText.indexOf('1 failed')).toBeGreaterThanOrEqual(0); + expect(summaryText.indexOf('1 failed')).toBeLessThan( + summaryText.indexOf('2/2 done'), + ); + }); + + it('counts a cancelled agent in the failed count', () => { + const container = renderExpandedGroup([ + agent({ callId: 'done', status: 'completed' }), + agent({ + callId: 'cancelled', + status: 'completed', + rawOutput: { + type: 'task_execution', + status: 'cancelled', + reason: 'Cancelled by user', + }, + }), + ]); + + expect(container.textContent).toContain('1 failed'); + }); + + it('shows the failed count alongside live progress', () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + try { + const container = renderExpandedGroup([ + agent({ + callId: 'done', + status: 'completed', + startTime: 1_000, + endTime: 5_000, + }), + agent({ + callId: 'failed', + status: 'failed', + startTime: 2_000, + endTime: 6_000, + }), + agent({ callId: 'running', status: 'pending', startTime: 3_000 }), + ]); + + expect(container.textContent).toContain('7s'); + expect(container.textContent).toContain('2/3 done'); + expect(container.textContent).toContain('1 failed'); + } finally { + vi.useRealTimers(); + } + }); + it('keeps the header clock monotonic when the earliest agent finishes', () => { vi.useFakeTimers(); vi.setSystemTime(150_000); diff --git a/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.tsx b/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.tsx index 2a69955cf4..5cf2e5a27c 100644 --- a/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.tsx +++ b/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.tsx @@ -4,12 +4,7 @@ import type { ACPToolCall, PermissionRequest } from '../../../adapters/types'; import { hasActiveAgents } from '../../../adapters/toolClassification'; import { useI18n } from '../../../i18n'; import { useSubagentDetails } from '../../../subagentDetailsContext'; -import { - formatElapsed, - formatLiveElapsed, - StatusIcon, - truncateText, -} from './toolDisplay'; +import { formatElapsed, formatLiveElapsed, truncateText } from './toolDisplay'; import { getTaskExecutionRecord, getAgentType, @@ -357,19 +352,15 @@ export function ParallelAgentsGroup({ const doneCount = agents.filter( (a) => a.status === 'completed' || a.status === 'failed', ).length; + const failedCount = agents.filter( + (a) => getAgentDisplayStatus(a) === 'failed', + ).length; const total = agents.length; const showGroup = groupExpanded || !!approvalAgent; const renderGroup = showGroup || automaticCollapseAnimating; const automaticCollapseClosing = automaticCollapseAnimating && !hasApprovalAgent; - const summaryStatus = agents.some( - (a) => getAgentDisplayStatus(a) === 'failed', - ) - ? 'failed' - : hasActive - ? 'in_progress' - : 'completed'; return (
@@ -396,15 +387,9 @@ export function ParallelAgentsGroup({ aria-expanded={showGroup} title={showGroup ? t('tool.collapseHint') : t('tool.expand')} > - {summaryStatus === 'failed' ? ( - - - - ) : ( - - )} + {t('parallelAgents.title')} {runningDuration && <> {runningDuration}} + {/* Ahead of the done counter so it survives the summaryText + tail truncation in narrow layouts. */} + {failedCount > 0 && ( + <> + · + {t('parallelAgents.failed', { count: failedCount })} + + )} · {t('parallelAgents.done', { done: doneCount, total })} 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 83ece04ff3..953fca8b3e 100644 --- a/packages/web-shell/client/components/messages/tools/SubAgentPanel.test.tsx +++ b/packages/web-shell/client/components/messages/tools/SubAgentPanel.test.tsx @@ -58,6 +58,21 @@ function makeAgentWithSubTool(subTool: ACPToolCall): ACPToolCall { } describe('SubAgentPanel sub-tool timestamps', () => { + it('marks a failed sub-tool with an error icon instead of text', () => { + const container = renderPanel( + makeAgentWithSubTool({ + callId: 'sub-1', + toolName: 'Read', + status: 'failed', + }), + ); + + const errorIcon = container.querySelector('[class*="iconError"]'); + expect(errorIcon).not.toBeNull(); + expect(errorIcon?.querySelector('svg')).not.toBeNull(); + expect(container.textContent).not.toContain('Failed'); + }); + it('renders completed result content through assistant markdown', () => { const container = renderPanel({ callId: 'agent-1', 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 4f5be7e985..22f780fa8c 100644 --- a/packages/web-shell/client/components/messages/tools/ToolChrome.module.css +++ b/packages/web-shell/client/components/messages/tools/ToolChrome.module.css @@ -333,8 +333,18 @@ padding: 8px 12px 8px; } +.expandedCardTitleRow { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + .expandedCardTitle { flex-shrink: 0; + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; color: var(--foreground); font-size: 12px; font-weight: 500; diff --git a/packages/web-shell/client/components/messages/tools/toolDisplay.tsx b/packages/web-shell/client/components/messages/tools/toolDisplay.tsx index dada8a7268..7bca10dca0 100644 --- a/packages/web-shell/client/components/messages/tools/toolDisplay.tsx +++ b/packages/web-shell/client/components/messages/tools/toolDisplay.tsx @@ -1,3 +1,4 @@ +import { CircleXIcon } from 'lucide-react'; import styles from './ToolChrome.module.css'; import { useI18n } from '../../../i18n'; export { @@ -17,8 +18,13 @@ export function StatusIcon({ status }: { status: string }) { case 'cancelled': case 'canceled': return ( - - {t('tool.status.failed')} + + ); case 'in_progress': diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 338a92d676..2caba1221a 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2201,6 +2201,7 @@ const EN: Messages = { 'resume.title': 'Resume Session', 'parallelAgents.title': 'Parallel agents', 'parallelAgents.done': (v) => `${v?.done ?? 0}/${v?.total ?? 0} done`, + 'parallelAgents.failed': (v) => `${v?.count ?? 0} failed`, 'skills.actions': 'Skill actions', 'skills.disable': 'Disable', 'skills.disabled': 'Skill disabled.', @@ -4987,6 +4988,7 @@ const ZH: Messages = { 'resume.title': '恢复会话', 'parallelAgents.title': '并行智能体', 'parallelAgents.done': (v) => `${v?.done ?? 0}/${v?.total ?? 0} 完成`, + 'parallelAgents.failed': (v) => `失败 ${v?.count ?? 0} 个`, 'skills.actions': 'Skill 操作', 'skills.disable': '禁用', 'skills.disabled': 'Skill 已禁用。',