import { memo, useCallback, useState } from 'react' import { BookMarked, ChevronDown, ChevronRight, Settings } from 'lucide-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 { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore' import { useUIStore } from '../../stores/uiStore' import type { AgentTaskNotification, UIMessage } from '../../types/chat' import { AGENT_LIFECYCLE_TYPES } from '../../types/team' type ToolCall = Extract type ToolResult = Extract type MemoryToolAction = 'saved' | 'referenced' type MemoryToolFile = { path: string label: string action: MemoryToolAction lineHint?: string preview?: string } type MemoryToolActivity = { action: MemoryToolAction files: MemoryToolFile[] } function useExpandableCardState() { const [expanded, setExpanded] = useState(false) const toggleExpanded = useCallback(() => { setExpanded((value) => !value) }, []) return { expanded, toggleExpanded } } type Props = { sessionId?: string | null toolCalls: ToolCall[] resultMap: Map childToolCallsByParent: Map agentTaskNotifications: Record showOpenRun?: boolean /** When true, the last tool is still executing. */ 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 toolCallHasError( toolCall: ToolCall, resultMap: Map, childToolCallsByParent: Map, ): boolean { const result = resultMap.get(toolCall.toolUseId) if (result?.isError) return true return (childToolCallsByParent.get(toolCall.toolUseId) ?? []).some((childToolCall) => toolCallHasError(childToolCall, resultMap, childToolCallsByParent), ) } function groupHasErrors( toolCalls: ToolCall[], resultMap: Map, childToolCallsByParent: Map, ): boolean { return toolCalls.some((tc) => { return toolCallHasError(tc, resultMap, childToolCallsByParent) }) } function isToolCallResolved( toolCall: ToolCall, resultMap: Map, childToolCallsByParent: Map, ): boolean { if (toolCall.status === 'stopped') return true if (!resultMap.has(toolCall.toolUseId)) return false return (childToolCallsByParent.get(toolCall.toolUseId) ?? []).every((childToolCall) => isToolCallResolved(childToolCall, resultMap, childToolCallsByParent), ) } function hasUnresolvedToolCalls( toolCalls: ToolCall[], resultMap: Map, childToolCallsByParent: Map, ): boolean { return toolCalls.some((toolCall) => !isToolCallResolved(toolCall, resultMap, childToolCallsByParent), ) } export const ToolCallGroup = memo(function ToolCallGroup({ sessionId, toolCalls, resultMap, childToolCallsByParent, agentTaskNotifications, showOpenRun = true, isStreaming, }: Props) { const memoryActivity = getMemoryToolActivity(toolCalls, resultMap) if (memoryActivity) { const memoryToolCalls = toolCalls.filter(isMemoryToolCall) const regularToolCalls = toolCalls.filter((toolCall) => !isMemoryToolCall(toolCall)) return (
0 ? 'mb-2 space-y-2' : ''}> {regularToolCalls.length > 0 ? ( ) : null}
) } return ( ) }) function ToolCallGroupContent({ sessionId, toolCalls, resultMap, childToolCallsByParent, agentTaskNotifications, showOpenRun = true, 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 MemoryToolActivityGroup({ activity, toolCalls, resultMap, childToolCallsByParent, isStreaming, }: { activity: MemoryToolActivity toolCalls: ToolCall[] resultMap: Map childToolCallsByParent: Map isStreaming?: boolean }) { const { expanded, toggleExpanded } = useExpandableCardState() const [detailsExpanded, setDetailsExpanded] = useState(false) const t = useTranslation() const titleKey = activity.action === 'saved' ? 'chat.memorySavedFromToolsTitle' : 'chat.memoryReferencedTitle' const visibleFiles = activity.files.slice(0, 4) const hiddenCount = Math.max(0, activity.files.length - visibleFiles.length) return (
{expanded ? (
{visibleFiles.map((file) => ( ))} {hiddenCount > 0 ? (
{t('chat.memoryMoreFiles', { count: hiddenCount })}
) : null}
{detailsExpanded ? (
{toolCalls.map((toolCall) => ( ))}
) : null}
) : null}
) } function AgentToolGroup({ sessionId, toolCalls, resultMap, childToolCallsByParent, agentTaskNotifications, showOpenRun = true, isStreaming, }: Props) { const { expanded, toggleExpanded } = useExpandableCardState() 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') return (
{expanded && (
{toolCalls.map((toolCall) => (
))}
)}
) } /** Separated so the useState hook is never called conditionally. */ function ToolCallGroupMulti({ toolCalls, resultMap, childToolCallsByParent, isStreaming }: Props) { const { expanded, toggleExpanded } = useExpandableCardState() const t = useTranslation() const summary = generateSummary(toolCalls, t) const errorPresent = groupHasErrors(toolCalls, resultMap, childToolCallsByParent) const hasUnresolvedTools = hasUnresolvedToolCalls(toolCalls, resultMap, childToolCallsByParent) const isRunning = !!isStreaming || hasUnresolvedTools return (
{expanded && (
{toolCalls.map((tc) => { return ( ) })}
)}
) } function AgentCallCard({ sessionId, toolCall, resultMap, childToolCallsByParent, agentTaskNotification, showOpenRun = true, isStreaming = false, }: { sessionId?: string | null toolCall: ToolCall resultMap: Map childToolCallsByParent: Map agentTaskNotification?: AgentTaskNotification showOpenRun?: boolean 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 taskResult = agentTaskNotification?.result?.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) ? extractAgentDisplayText(result.content).trim() : '' const terminalTaskReport = status === 'done' || status === 'stopped' ? taskResult : '' const terminalTaskSummary = status === 'done' || status === 'stopped' ? taskSummary : '' const previewText = terminalTaskReport || fullOutputText || terminalTaskSummary const outputSummary = previewText ? getAgentOutputSummary(previewText) : '' const description = typeof input.description === 'string' ? input.description : '' const openRunTitle = description.trim() || 'Agent' const canOpenRun = showOpenRun && !!sessionId && !!toolCall.toolUseId 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 && ( )} {canOpenRun && ( )} {statusLabel}
{expanded && (
{errorText && (
{errorText}
)} {childToolCalls.length > 0 ? (
{childToolCalls.map((childToolCall) => ( ))}
) : outputSummary ? (
{t('agentStatus.noActivity')}
) : (
{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) => ( ))}
)}
) } function openMemorySettings(path?: string) { const ui = useUIStore.getState() if (path) ui.setPendingMemoryPath(path) ui.setPendingSettingsTab('memory') useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings') } function getMemoryToolActivity( toolCalls: ToolCall[], resultMap: Map, ): MemoryToolActivity | null { const filesByPath = new Map() let sawSave = false for (const toolCall of toolCalls) { if (toolCall.isPending) continue const path = getToolFilePath(toolCall.input) if (!path || !isMemoryMarkdownPath(path)) continue const isSave = isMemoryWriteTool(toolCall.toolName) const isReference = toolCall.toolName === 'Read' if (!isSave && !isReference) continue sawSave ||= isSave const result = resultMap.get(toolCall.toolUseId) const preview = extractMemoryPreview(result?.content) const current = filesByPath.get(path) filesByPath.set(path, { path, label: memoryFileLabel(path), action: isSave ? 'saved' : (current?.action ?? 'referenced'), lineHint: preview.lineHint || current?.lineHint, preview: preview.text || current?.preview, }) } if (filesByPath.size === 0) return null return { action: sawSave ? 'saved' : 'referenced', files: [...filesByPath.values()], } } function isMemoryToolCall(toolCall: ToolCall): boolean { if (toolCall.isPending) return false const path = getToolFilePath(toolCall.input) if (!path || !isMemoryMarkdownPath(path)) return false return toolCall.toolName === 'Read' || isMemoryWriteTool(toolCall.toolName) } function isMemoryWriteTool(toolName: string): boolean { return toolName === 'Write' || toolName === 'Edit' || toolName === 'MultiEdit' } function getToolFilePath(input: unknown): string | null { if (!input || typeof input !== 'object') return null const record = input as Record const filePath = record.file_path ?? record.path return typeof filePath === 'string' ? filePath : null } function isMemoryMarkdownPath(path: string): boolean { const normalized = path.replace(/\\/g, '/') return normalized.endsWith('.md') && normalized.includes('/memory/') } function memoryFileLabel(path: string): string { const normalized = path.replace(/\\/g, '/') return normalized.split('/').pop() || normalized } function extractMemoryPreview(content: unknown): { text?: string; lineHint?: string } { const raw = extractTextContent(content) if (!raw) return {} const lineHint = extractLineHint(raw) const lines = raw .replace(/[\s\S]*?<\/system-reminder>/g, '') .split(/\r?\n/) .map((line) => line.replace(/^\s*\d+\s*/, '').trim()) .filter(Boolean) let inFrontmatter = false for (const line of lines) { if (line === '---') { inFrontmatter = !inFrontmatter continue } if (inFrontmatter) continue const normalized = line.replace(/^#+\s*/, '').replace(/^[-*]\s*/, '').trim() if (!normalized || normalized === '---') continue if (/^(file|lines?|total)\b/i.test(normalized)) continue return { text: normalized.length > 140 ? `${normalized.slice(0, 140)}...` : normalized, lineHint, } } return { lineHint } } function extractLineHint(text: string): string | undefined { const match = text.match(/(\d+)\s+lines?\b/i) ?? text.match(/(\d+)\s+行/) return match?.[1] ? `${match[1]} lines` : undefined } 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 extractAgentDisplayText(content: unknown): string { return stripAgentResultMetadata(formatAgentStructuredResult(content) || extractTextContent(content)) } function formatAgentStructuredResult(content: unknown): string { const structured = parseStructuredAgentContent(content) if (!structured || Array.isArray(structured)) return '' const results = structured.results if (!Array.isArray(results) || results.length === 0) return '' const items = results .map((result, index) => formatAgentStructuredResultItem(result, index)) .filter(Boolean) return items.join('\n') } function parseStructuredAgentContent(content: unknown): Record | unknown[] | null { if (typeof content === 'string') { return parseStructuredAgentText(content) } if (Array.isArray(content)) { return parseStructuredAgentText(extractTextContent(content)) } if (content && typeof content === 'object') { if ('results' in content) return content as Record const extracted = extractTextContent(content) return extracted ? parseStructuredAgentText(extracted) : null } return null } function parseStructuredAgentText(text: string): Record | unknown[] | null { const trimmed = text.trim() if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return null try { const parsed = JSON.parse(trimmed) as unknown return typeof parsed === 'object' && parsed !== null ? parsed as Record | unknown[] : null } catch { return null } } function formatAgentStructuredResultItem(result: unknown, index: number): string { if (!result || typeof result !== 'object' || Array.isArray(result)) { const text = extractTextContent(result).trim() return text ? `${index + 1}. ${text}` : '' } const record = result as Record const location = formatAgentResultLocation(record) const context = getStringField(record, 'context') const snippet = getStringField(record, 'snippet') const message = getStringField(record, 'message') || getStringField(record, 'text') || getStringField(record, 'summary') const nestedItems = Array.isArray(record.items) ? record.items : [] if (nestedItems.length > 0) { const label = getStringField(record, 'risk') || getStringField(record, 'title') || message || 'Grouped results' const lines = [`${index + 1}. ${formatAgentGroupLabel(label)}`] if (context) lines.push(` - ${context}`) if (snippet) lines.push(` - ${snippet}`) nestedItems .map(formatAgentStructuredNestedItem) .filter(Boolean) .forEach((item) => { lines.push( item .split('\n') .map((line, lineIndex) => `${lineIndex === 0 ? ' - ' : ' '}${line}`) .join('\n'), ) }) return lines.join('\n') } const lines = [`${index + 1}. ${location ? formatInlineCode(location) : 'Result'}`] if (message) lines.push(` - ${message}`) if (context) lines.push(` - ${context}`) if (snippet) lines.push(` - ${snippet}`) return lines.join('\n') } function formatAgentStructuredNestedItem(item: unknown): string { if (!item || typeof item !== 'object' || Array.isArray(item)) { return extractTextContent(item).trim() } const record = item as Record const location = formatAgentResultLocation(record) const context = getStringField(record, 'context') const snippet = getStringField(record, 'snippet') const message = getStringField(record, 'message') || getStringField(record, 'text') || getStringField(record, 'summary') const headingParts = [location ? formatInlineCode(location) : '', message].filter(Boolean) const lines = [headingParts.join(' - ') || 'Result'] if (context) lines.push(context) if (snippet) lines.push(snippet) return lines.join('\n') } function formatAgentGroupLabel(label: string): string { const normalized = label.trim() if (!normalized) return 'Grouped results' return `${normalized.charAt(0).toUpperCase()}${normalized.slice(1)}` } function formatAgentResultLocation(record: Record): string { const file = getStringField(record, 'file') if (!file) return '' const line = typeof record.line === 'number' ? record.line : null return line !== null ? `${file}:${line}` : file } function getStringField(record: Record, key: string): string { const value = record[key] return typeof value === 'string' ? value.trim() : '' } function formatInlineCode(value: string): string { return `\`${value.replace(/`/g, '\\`')}\`` } function stripAgentResultMetadata(text: string): string { return text .replace(/^\s*agentId:.*(?:\r?\n)?/gm, '') .replace(/[\s\S]*?<\/usage>/g, '') .replace(/^\s*(?:total_tokens|tool_uses|duration_ms):\s*\d+\s*$/gm, '') .replace(/\n{3,}/g, '\n\n') .trim() } 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 '' }