From 458945da52483f3d73a5c0bed708fd98539683bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 6 Apr 2026 21:35:04 +0800 Subject: [PATCH] feat: add ToolCallGroup + compact ToolCallBlock for collapsible tool summaries Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src/components/chat/ToolCallBlock.tsx | 99 +++++--------- desktop/src/components/chat/ToolCallGroup.tsx | 121 ++++++++++++++++++ 2 files changed, 155 insertions(+), 65 deletions(-) create mode 100644 desktop/src/components/chat/ToolCallGroup.tsx diff --git a/desktop/src/components/chat/ToolCallBlock.tsx b/desktop/src/components/chat/ToolCallBlock.tsx index 034d341c..63516c60 100644 --- a/desktop/src/components/chat/ToolCallBlock.tsx +++ b/desktop/src/components/chat/ToolCallBlock.tsx @@ -8,6 +8,7 @@ type Props = { toolName: string input: unknown result?: { content: unknown; isError: boolean } | null + compact?: boolean } const TOOL_ICONS: Record = { @@ -24,33 +25,22 @@ const TOOL_ICONS: Record = { Skill: 'auto_awesome', } -const STATUS_BADGE: Record = { - Edit: { label: 'MODIFIED', className: 'bg-[var(--color-primary-fixed)] text-[#75331C]' }, - Write: { label: 'CREATED', className: 'bg-[#D4EAB4] text-[#3B4C24]' }, - Read: { label: 'READ', className: 'bg-[var(--color-surface-container-high)] text-[var(--color-outline)]' }, - Bash: { label: 'EXECUTED', className: 'bg-[var(--color-surface-container-high)] text-[var(--color-outline)]' }, -} - -export function ToolCallBlock({ toolName, input, result }: Props) { +export function ToolCallBlock({ toolName, input, result, compact = false }: Props) { const [expanded, setExpanded] = useState(false) const obj = input && typeof input === 'object' ? (input as Record) : {} const icon = TOOL_ICONS[toolName] || 'build' const filePath = typeof obj.file_path === 'string' ? obj.file_path : '' const summary = getToolSummary(toolName, obj) const outputSummary = getToolResultSummary(toolName, result?.content) - const inlineError = getInlineErrorSummary(result) - const badge = result - ? result.isError - ? { label: 'ERROR', className: 'bg-[var(--color-error-container)] text-[var(--color-error)]' } - : { label: 'SUCCESS', className: 'bg-[#D4EAB4] text-[#3B4C24]' } - : STATUS_BADGE[toolName] const preview = useMemo(() => renderPreview(toolName, obj, result), [obj, result, toolName]) const details = useMemo(() => renderDetails(toolName, obj), [obj, toolName]) const expandable = toolName === 'Edit' || toolName === 'Write' return ( -
+
{expandable && expanded && ( @@ -205,15 +183,6 @@ function getToolResultSummary(toolName: string, content: unknown): string { return `${compact.slice(0, 36)}…` } -function getInlineErrorSummary(result?: { content: unknown; isError: boolean } | null) { - if (!result?.isError) return '' - - const text = extractTextContent(result.content)?.replace(/\s+/g, ' ').trim() || '' - if (!text) return 'Tool failed' - if (text.length <= 120) return text - return `${text.slice(0, 120)}…` -} - function getToolSummary(toolName: string, obj: Record): string { switch (toolName) { case 'Bash': diff --git a/desktop/src/components/chat/ToolCallGroup.tsx b/desktop/src/components/chat/ToolCallGroup.tsx new file mode 100644 index 00000000..e9fca8d3 --- /dev/null +++ b/desktop/src/components/chat/ToolCallGroup.tsx @@ -0,0 +1,121 @@ +import { useState } from 'react' +import { ToolCallBlock } from './ToolCallBlock' +import type { UIMessage } from '../../types/chat' + +type ToolCall = Extract +type ToolResult = Extract + +type Props = { + toolCalls: ToolCall[] + resultMap: Map + /** When true, the last tool is still executing — show expanded */ + isStreaming?: boolean +} + +const TOOL_VERBS: Record string> = { + Read: (n) => `Read ${n} file${n > 1 ? 's' : ''}`, + Write: (n) => `created ${n > 1 ? `${n} files` : 'a file'}`, + Edit: (n) => `edited ${n > 1 ? `${n} files` : 'a file'}`, + Bash: (n) => `ran ${n > 1 ? `${n} commands` : 'a command'}`, + Glob: (_n) => `found files`, + Grep: (n) => `searched ${n > 1 ? `${n} patterns` : 'code'}`, + Agent: (n) => `dispatched ${n > 1 ? `${n} agents` : 'an agent'}`, + WebSearch: (_n) => `searched the web`, + WebFetch: (n) => `fetched ${n > 1 ? `${n} pages` : 'a page'}`, +} + +function generateSummary(toolCalls: ToolCall[]): string { + const counts = new Map() + for (const tc of toolCalls) { + counts.set(tc.toolName, (counts.get(tc.toolName) ?? 0) + 1) + } + + const parts: string[] = [] + for (const [name, count] of counts) { + const verbFn = TOOL_VERBS[name] + parts.push(verbFn ? verbFn(count) : `${name} (${count})`) + } + + return parts.join(', ') +} + +function groupHasErrors(toolCalls: ToolCall[], resultMap: Map): boolean { + return toolCalls.some((tc) => { + const result = resultMap.get(tc.toolUseId) + return result?.isError + }) +} + +export function ToolCallGroup({ toolCalls, resultMap, isStreaming }: Props) { + // Single tool call — render directly without group wrapper + if (toolCalls.length === 1) { + const tc = toolCalls[0]! + const result = resultMap.get(tc.toolUseId) + return ( + + ) + } + + return +} + +/** Separated so the useState hook is never called conditionally. */ +function ToolCallGroupMulti({ toolCalls, resultMap, isStreaming }: Props) { + const [expanded, setExpanded] = useState(false) + const summary = generateSummary(toolCalls) + const errorPresent = groupHasErrors(toolCalls, resultMap) + const allComplete = toolCalls.every((tc) => resultMap.has(tc.toolUseId)) + + return ( +
+ + + {expanded && ( +
+ {toolCalls.map((tc) => { + const result = resultMap.get(tc.toolUseId) + return ( + + ) + })} +
+ )} +
+ ) +}