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 已禁用。',