import { useEffect, useState } from 'react' import { ToolCallBlock } from './ToolCallBlock' import { MarkdownRenderer } from '../markdown/MarkdownRenderer' import { Modal } from '../shared/Modal' import { useTranslation } from '../../i18n' import type { UIMessage } from '../../types/chat' type ToolCall = Extract type ToolResult = Extract type Props = { toolCalls: ToolCall[] resultMap: Map childToolCallsByParent: Map /** When true, the last tool is still executing — show expanded */ isStreaming?: boolean } const TOOL_VERBS: Record string) => string> = { Read: (n, t) => n === 1 ? t('toolGroup.readOne') : t('toolGroup.readMany', { count: n }), Write: (n, t) => n === 1 ? t('toolGroup.createdOne') : t('toolGroup.createdMany', { count: n }), Edit: (n, t) => n === 1 ? t('toolGroup.editedOne') : t('toolGroup.editedMany', { count: n }), Bash: (n, t) => n === 1 ? t('toolGroup.ranOne') : t('toolGroup.ranMany', { count: n }), Glob: (_n, t) => t('toolGroup.foundFiles'), Grep: (n, t) => n === 1 ? t('toolGroup.searchedOne') : t('toolGroup.searchedMany', { count: n }), Agent: (n, t) => n === 1 ? t('toolGroup.agentOne') : t('toolGroup.agentMany', { count: n }), WebSearch: (_n, t) => t('toolGroup.searchedWeb'), WebFetch: (n, t) => n === 1 ? t('toolGroup.fetchedOne') : t('toolGroup.fetchedMany', { count: n }), } function generateSummary(toolCalls: ToolCall[], t: (key: any, params?: any) => string): 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, t) : `${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, childToolCallsByParent, isStreaming }: Props) { const allAgents = toolCalls.every((toolCall) => toolCall.toolName === 'Agent') if (allAgents) { return ( ) } // Single tool call — render directly without group wrapper if (toolCalls.length === 1) { const tc = toolCalls[0]! return ( ) } return ( ) } function AgentToolGroup({ toolCalls, resultMap, childToolCallsByParent, isStreaming }: Props) { const [expanded, setExpanded] = useState(true) const t = useTranslation() const errorPresent = groupHasErrors(toolCalls, resultMap) const allComplete = toolCalls.every((tc) => resultMap.has(tc.toolUseId)) useEffect(() => { if (isStreaming) { setExpanded(true) } }, [isStreaming]) return (
{expanded && (
{toolCalls.map((toolCall) => (
))}
)}
) } /** Separated so the useState hook is never called conditionally. */ function ToolCallGroupMulti({ toolCalls, resultMap, childToolCallsByParent, isStreaming }: Props) { const [expanded, setExpanded] = useState(false) const t = useTranslation() const summary = generateSummary(toolCalls, t) const errorPresent = groupHasErrors(toolCalls, resultMap) const allComplete = toolCalls.every((tc) => resultMap.has(tc.toolUseId)) const hasNestedToolCalls = toolCalls.some((tc) => (childToolCallsByParent.get(tc.toolUseId)?.length ?? 0) > 0) useEffect(() => { if (isStreaming || hasNestedToolCalls) { setExpanded(true) } }, [hasNestedToolCalls, isStreaming]) return (
{expanded && (
{toolCalls.map((tc) => { return ( ) })}
)}
) } function AgentCallCard({ toolCall, resultMap, childToolCallsByParent, isStreaming = false, }: { toolCall: ToolCall resultMap: Map childToolCallsByParent: Map isStreaming?: boolean }) { const [expanded, setExpanded] = useState(false) const [previewOpen, setPreviewOpen] = useState(false) const t = useTranslation() const input = toolCall.input && typeof toolCall.input === 'object' ? toolCall.input as Record : {} const result = resultMap.get(toolCall.toolUseId) const childToolCalls = childToolCallsByParent.get(toolCall.toolUseId) ?? [] const recentToolCalls = childToolCalls.slice(-2) const status = getAgentStatus({ hasResult: !!result, isError: !!result?.isError, isStreaming, childCount: childToolCalls.length, }) const statusClassName = getAgentStatusClassName(status) const statusLabel = getAgentStatusLabel(status, t) const errorText = result?.isError ? getAgentErrorSummary(result.content) : '' const fullOutputText = result && !result.isError ? extractTextContent(result.content).trim() : '' const outputSummary = fullOutputText ? getAgentOutputSummary(fullOutputText) : '' const description = typeof input.description === 'string' ? input.description : '' return (
smart_toy
Agent {description && ( {description} )}
{!expanded && recentToolCalls.length > 0 && (
{recentToolCalls.map((recentToolCall) => (
{formatRecentToolUseSummary(recentToolCall, resultMap)}
))}
)} {!expanded && !recentToolCalls.length && errorText && (
{errorText}
)} {!expanded && !recentToolCalls.length && !errorText && outputSummary && (
{outputSummary}
)}
{outputSummary && ( )} {statusLabel}
{expanded && (
{errorText && (
{errorText}
)} {childToolCalls.length > 0 ? (
{childToolCalls.map((childToolCall) => ( ))}
) : outputSummary ? (
{outputSummary}
{t('agentStatus.viewResult')}
) : (
{status === 'starting' ? t('agentStatus.starting') : t('agentStatus.noActivity')}
)}
)} setPreviewOpen(false)} title={description || t('agentStatus.resultTitle')} width={900} >
) } function ToolCallTree({ toolCall, resultMap, childToolCallsByParent, compact = false, }: { toolCall: ToolCall resultMap: Map childToolCallsByParent: Map compact?: boolean }) { const result = resultMap.get(toolCall.toolUseId) const childToolCalls = childToolCallsByParent.get(toolCall.toolUseId) ?? [] return (
{childToolCalls.length > 0 && (
{childToolCalls.map((childToolCall) => ( ))}
)}
) } type AgentStatus = 'starting' | 'running' | 'done' | 'failed' function getAgentStatus({ hasResult, isError, isStreaming, childCount, }: { hasResult: boolean isError: boolean isStreaming: boolean childCount: number }): AgentStatus { if (hasResult && isError) return 'failed' if (hasResult) return 'done' if (isStreaming || childCount > 0) return 'running' return 'starting' } function getAgentStatusLabel( status: AgentStatus, t: (key: any, params?: any) => string, ): string { switch (status) { case 'failed': return t('agentStatus.failed') case 'done': return t('agentStatus.done') case 'running': return t('agentStatus.running') case 'starting': default: return t('agentStatus.starting') } } function getAgentStatusClassName(status: AgentStatus): string { switch (status) { case 'failed': return 'bg-[var(--color-error)]/10 text-[var(--color-error)]' case 'done': return 'bg-[var(--color-success)]/10 text-[var(--color-success)]' case 'running': return 'bg-[var(--color-warning)]/10 text-[var(--color-warning)]' case 'starting': default: return 'bg-[var(--color-surface-container-high)] text-[var(--color-text-secondary)]' } } function formatRecentToolUseSummary( toolCall: ToolCall, resultMap: Map, ): string { const input = toolCall.input && typeof toolCall.input === 'object' ? toolCall.input as Record : {} const result = resultMap.get(toolCall.toolUseId) const suffix = result?.isError ? ' • failed' : result ? ' • done' : ' • running' switch (toolCall.toolName) { case 'Bash': return `Bash · ${typeof input.command === 'string' ? input.command : ''}${suffix}` case 'Read': return `Read · ${typeof input.file_path === 'string' ? input.file_path.split('/').pop() : 'file'}${suffix}` case 'Glob': return `Glob · ${typeof input.pattern === 'string' ? input.pattern : ''}${suffix}` case 'Grep': return `Grep · ${typeof input.pattern === 'string' ? input.pattern : ''}${suffix}` case 'Agent': return `Agent · ${typeof input.description === 'string' ? input.description : ''}${suffix}` default: return `${toolCall.toolName}${suffix}` } } function getAgentErrorSummary(content: unknown): string { const text = extractTextContent(content).replace(/\s+/g, ' ').trim() if (!text) return '' if (text.includes(`Agent type 'Explore' not found`)) { return 'Explore agent unavailable in this session' } return text.length > 120 ? `${text.slice(0, 120)}...` : text } function getAgentOutputSummary(content: string): string { const text = content.replace(/\s+\n/g, '\n').trim() if (!text) return '' return text.length > 220 ? `${text.slice(0, 220)}...` : text } function extractTextContent(content: unknown): string { if (typeof content === 'string') return content if (Array.isArray(content)) { return content .map((chunk) => { if (typeof chunk === 'string') return chunk if (chunk && typeof chunk === 'object' && 'text' in chunk) { return typeof chunk.text === 'string' ? chunk.text : '' } return '' }) .filter(Boolean) .join('\n') } if (content && typeof content === 'object') { if ( 'status' in content && (content as Record).status === 'completed' && Array.isArray((content as Record).content) ) { return extractTextContent((content as Record).content) } } if (content && typeof content === 'object') { return JSON.stringify(content) } return '' }