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 { TranslationKey } from '../../i18n' import type { AgentTaskNotification, UIMessage } from '../../types/chat' import { AGENT_LIFECYCLE_TYPES } from '../../types/team' type ToolCall = Extract type ToolResult = Extract type Props = { toolCalls: ToolCall[] resultMap: Map childToolCallsByParent: Map agentTaskNotifications: Record /** 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: TranslationKey, params?: Record) => 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, agentTaskNotifications, 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, agentTaskNotifications, isStreaming, }: Props) { const [expanded, setExpanded] = useState(true) const t = useTranslation() const statuses = toolCalls.map((toolCall) => getAgentStatus({ hasResult: resultMap.has(toolCall.toolUseId), isError: !!resultMap.get(toolCall.toolUseId)?.isError, isLaunchResult: isAgentLaunchResult(resultMap.get(toolCall.toolUseId)?.content), isStreaming: !!isStreaming && !resultMap.has(toolCall.toolUseId), childCount: (childToolCallsByParent.get(toolCall.toolUseId) ?? []).length, taskStatus: agentTaskNotifications[toolCall.toolUseId]?.status, }), ) const isAnyRunning = statuses.some((status) => status === 'running' || status === 'starting') const errorPresent = statuses.some((status) => status === 'failed') const allComplete = statuses.every((status) => status === 'done') const anyStopped = statuses.some((status) => status === 'stopped') 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, agentTaskNotification, isStreaming = false, }: { toolCall: ToolCall resultMap: Map childToolCallsByParent: Map agentTaskNotification?: AgentTaskNotification 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 isLaunchResult = isAgentLaunchResult(result?.content) const recentToolCalls = childToolCalls.slice(-2) const status = getAgentStatus({ hasResult: !!result, isError: !!result?.isError, isLaunchResult, isStreaming, childCount: childToolCalls.length, taskStatus: agentTaskNotification?.status, }) const statusClassName = getAgentStatusClassName(status) const statusLabel = getAgentStatusLabel(status, t) const taskSummary = agentTaskNotification?.summary?.trim() || '' const errorText = status === 'failed' ? taskSummary || (result?.isError ? getAgentErrorSummary(result.content) : '') : result?.isError ? getAgentErrorSummary(result.content) : '' const fullOutputText = result && !result.isError && !isLaunchResult && !isAgentLifecycleResult(result.content) ? extractTextContent(result.content).trim() : '' const previewText = fullOutputText || (status === 'done' || status === 'stopped' ? taskSummary : '') const outputSummary = previewText ? getAgentOutputSummary(previewText) : '' const description = typeof input.description === 'string' ? input.description : '' return (
smart_toy
Agent {description && ( {description} )}
{!expanded && outputSummary && (
{outputSummary}
)} {!expanded && !outputSummary && recentToolCalls.length > 0 && (
{recentToolCalls.map((recentToolCall) => (
{formatRecentToolUseSummary(recentToolCall, resultMap)}
))}
)} {!expanded && !outputSummary && !recentToolCalls.length && errorText && (
{errorText}
)}
{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' | 'stopped' type AgentTaskStatus = AgentTaskNotification['status'] function getAgentStatus({ hasResult, isError, isLaunchResult, isStreaming, childCount, taskStatus, }: { hasResult: boolean isError: boolean isLaunchResult: boolean isStreaming: boolean childCount: number taskStatus?: AgentTaskStatus }): AgentStatus { if (taskStatus === 'failed') return 'failed' if (taskStatus === 'stopped') return 'stopped' if (taskStatus === 'completed') return 'done' if (hasResult && isError && !isLaunchResult) return 'failed' if (hasResult && !isLaunchResult) return 'done' if (isStreaming || childCount > 0 || isLaunchResult) return 'running' return 'starting' } function getAgentStatusLabel( status: AgentStatus, t: (key: TranslationKey, params?: Record) => string, ): string { switch (status) { case 'failed': return t('agentStatus.failed') case 'stopped': return t('agentStatus.stopped') 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 'stopped': return 'bg-[var(--color-surface-container-high)] text-[var(--color-text-secondary)]' 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 isAgentLaunchResult(content: unknown): boolean { const text = extractTextContent(content).trim() if (!text) return false return ( text.startsWith('Async agent launched successfully.') || text.startsWith('Remote agent launched in CCR.') || (text.startsWith('Spawned successfully.') && text.includes('The agent is now running and will receive instructions via mailbox.')) || text.includes('The agent is working in the background. You will be notified automatically when it completes.') || text.includes('The agent is running remotely. You will be notified automatically when it completes.') ) } /** * Check if agent result content is a lifecycle notification (shutdown, terminated, etc.) * rather than actual agent output. These should not be shown to the user as results. */ function isAgentLifecycleResult(content: unknown): boolean { const text = extractTextContent(content).trim() if (!text) return false // Detect JSON lifecycle messages: shutdown_approved, shutdown_rejected, teammate_terminated if (text.startsWith('{') && text.endsWith('}')) { try { const parsed = JSON.parse(text) as Record if (typeof parsed.type === 'string' && AGENT_LIFECYCLE_TYPES.has(parsed.type)) { return true } } catch { // Not valid JSON, not a lifecycle message } } return false } 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 '' }