import { useState, useEffect } from 'react'; import { ChevronRight, CheckCircle2, XCircle, Loader2, AlertCircle, Shield, FileText, FileEdit, FilePlus, Trash2, Terminal, Search, Copy, Check, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from './ui/button'; import { CodePreview } from './CodePreview'; import type { ToolDisplayData } from '@dexto/core'; /** * Sub-agent progress data for spawn_agent tool calls */ export interface SubAgentProgress { task: string; agentId: string; toolsCalled: number; currentTool: string; currentArgs?: Record; } export interface ToolCallTimelineProps { toolName: string; toolArgs?: Record; toolResult?: unknown; success?: boolean; requireApproval?: boolean; approvalStatus?: 'pending' | 'approved' | 'rejected'; displayData?: ToolDisplayData; subAgentProgress?: SubAgentProgress; onApprove?: (formData?: Record, rememberChoice?: boolean) => void; onReject?: () => void; } // ============================================================================= // Helpers // ============================================================================= function stripToolPrefix(toolName: string): { displayName: string; source: string } { if (toolName.startsWith('internal--')) { return { displayName: toolName.replace('internal--', ''), source: '' }; } if (toolName.startsWith('custom--')) { return { displayName: toolName.replace('custom--', ''), source: '' }; } if (toolName.startsWith('mcp--')) { const parts = toolName.split('--'); if (parts.length >= 3) { return { displayName: parts.slice(2).join('--'), source: parts[1] ?? '' }; } return { displayName: toolName.replace('mcp--', ''), source: 'mcp' }; } if (toolName.startsWith('mcp__')) { const parts = toolName.substring(5).split('__'); if (parts.length >= 2) { return { displayName: parts.slice(1).join('__'), source: parts[0] ?? '' }; } return { displayName: toolName.substring(5), source: 'mcp' }; } if (toolName.startsWith('internal__')) { return { displayName: toolName.substring(10), source: '' }; } return { displayName: toolName, source: '' }; } function getShortPath(path: string): string { const parts = path.split('/').filter(Boolean); if (parts.length <= 2) return path; return `.../${parts.slice(-2).join('/')}`; } function truncate(text: string, maxLength: number): string { if (text.length <= maxLength) return text; return text.substring(0, maxLength) + '...'; } function getSummary( displayName: string, toolArgs?: Record ): { name: string; detail?: string } { const args = toolArgs || {}; const filePath = (args.file_path || args.path || args.file) as string | undefined; const command = args.command as string | undefined; const pattern = (args.pattern || args.query) as string | undefined; if (command) { return { name: displayName, detail: truncate(command, 40) }; } if (filePath) { return { name: displayName, detail: getShortPath(filePath) }; } if (pattern) { return { name: displayName, detail: `"${truncate(pattern, 25)}"` }; } return { name: displayName }; } // ============================================================================= // Main Component // ============================================================================= export function ToolCallTimeline({ toolName, toolArgs, toolResult, success, requireApproval = false, approvalStatus, displayData, subAgentProgress, onApprove, onReject, }: ToolCallTimelineProps) { const hasResult = toolResult !== undefined; const isPendingApproval = requireApproval && approvalStatus === 'pending'; const isFailed = success === false; const isRejected = approvalStatus === 'rejected'; // Tool is processing only if: no result yet, not pending approval, and not marked as failed // The `success === false` check handles incomplete tool calls from history (never got a result) const isProcessing = !hasResult && !isPendingApproval && !isFailed; const hasSubAgentProgress = !!subAgentProgress; // Determine if there's meaningful content to show const hasExpandableContent = Boolean( displayData || toolArgs?.content || (toolArgs?.old_string && toolArgs?.new_string) || (toolArgs?.command && hasResult) ); // Determine if this tool has rich UI that should be shown by default // Rich UI includes: displayData, file content previews, and diff views // Exclude bash commands as they're more variable in visual value const hasRichUI = Boolean( displayData || toolArgs?.content || (toolArgs?.old_string && toolArgs?.new_string) ); // Smart default: expand for pending approvals and successful tools with rich UI // Failed, rejected, and no-output should always be collapsed const [expanded, setExpanded] = useState( isPendingApproval || (hasRichUI && !isFailed && !isRejected) ); const [detailsExpanded, setDetailsExpanded] = useState(false); const [copied, setCopied] = useState(false); // Auto-collapse after approval is resolved, but keep open if tool has rich UI and succeeded useEffect(() => { if (requireApproval && approvalStatus && approvalStatus !== 'pending') { // Collapse if rejected or if no rich UI to show if (isRejected || !hasRichUI) { setExpanded(false); } } }, [requireApproval, approvalStatus, hasRichUI, isRejected]); const { displayName, source } = stripToolPrefix(toolName); const summary = getSummary(displayName, toolArgs); // For sub-agent progress, format the agent name nicely const subAgentLabel = hasSubAgentProgress ? subAgentProgress.agentId .replace(/-agent$/, '') .charAt(0) .toUpperCase() + subAgentProgress.agentId.replace(/-agent$/, '').slice(1) : null; // Status icon const StatusIcon = isPendingApproval ? (
) : isProcessing ? ( ) : isFailed || isRejected ? ( ) : ( ); // Header click handler const toggleExpanded = () => { if (hasResult || isPendingApproval || hasExpandableContent) { setExpanded(!expanded); } }; const canExpand = hasResult || isPendingApproval || hasExpandableContent; return (
{/* Collapsed Header - Always Visible */} {/* Expanded Content */} {expanded && (
{/* Pending Approval Content */} {isPendingApproval && ( <> {renderApprovalPreview()}
)} {/* Error Content */} {isFailed && hasResult && renderErrorContent()} {/* Result Content */} {hasResult && !isFailed && !isPendingApproval && renderResultContent()}
)}
); // ========================================================================= // Render Functions // ========================================================================= function renderApprovalPreview() { const command = toolArgs?.command as string | undefined; const filePath = (toolArgs?.file_path || toolArgs?.path) as string | undefined; const content = toolArgs?.content as string | undefined; const oldString = toolArgs?.old_string as string | undefined; const newString = toolArgs?.new_string as string | undefined; // Bash command if (command) { return (
                        $ 
                        {command}
                    
); } // Edit operation - diff view without header (file path is in summary) if (oldString !== undefined && newString !== undefined) { return (
{oldString .split('\n') .slice(0, detailsExpanded ? 15 : 3) .map((line, i) => (
- {line || ' '}
))} {newString .split('\n') .slice(0, detailsExpanded ? 15 : 3) .map((line, i) => (
+ {line || ' '}
))} {(oldString.split('\n').length > 3 || newString.split('\n').length > 3) && ( )}
); } // Write/Create file if (content && filePath) { return (
); } return null; } function renderErrorContent() { let errorMessage = 'Unknown error'; if (toolResult && typeof toolResult === 'object') { const result = toolResult as Record; if (result.content && Array.isArray(result.content)) { const textPart = result.content.find( (p: unknown) => typeof p === 'object' && p !== null && (p as Record).type === 'text' ) as { text?: string } | undefined; if (textPart?.text) errorMessage = textPart.text; } else if (result.error) { errorMessage = typeof result.error === 'string' ? result.error : JSON.stringify(result.error); } } return (
                    {truncate(errorMessage, 500)}
                
); } function renderResultContent() { // Extract toolArgs for checking rich content availability const command = toolArgs?.command as string | undefined; const filePath = (toolArgs?.file_path || toolArgs?.path) as string | undefined; const content = toolArgs?.content as string | undefined; const oldString = toolArgs?.old_string as string | undefined; const newString = toolArgs?.new_string as string | undefined; // Check if we have rich content that should override simple displayData const hasRichContent = !!( content || (oldString !== undefined && newString !== undefined) || (command && hasResult) ); // If we have display metadata from tool, use it (unless we have richer content) if (displayData) { // Skip simple file metadata display if we have rich content to show if (displayData.type === 'file' && hasRichContent) { // Fall through to render rich content below } else { switch (displayData.type) { case 'diff': return renderDiff(displayData); case 'shell': return renderShell(displayData); case 'search': return renderSearch(displayData); case 'file': return renderFile(displayData); } } } // Render rich content from toolArgs // Bash command with result if (command && hasResult) { return renderBashResult(command); } // Edit operation (old_string -> new_string) if (oldString !== undefined && newString !== undefined && filePath) { return renderEditResult(filePath, oldString, newString); } // Write/create file if (content && filePath) { return renderWriteResult(filePath, content); } // Read file - show content from result if (displayName.toLowerCase().includes('read') && filePath) { return renderReadResult(filePath); } // Fallback to generic return renderGenericResult(); } function renderDiff(data: Extract) { const lines = data.unified .split('\n') .filter((l) => !l.startsWith('@@') && !l.startsWith('---') && !l.startsWith('+++')); const displayLines = lines.slice(0, detailsExpanded ? 40 : 8); return (
{getShortPath(data.filename)} +{data.additions} -{data.deletions}
{displayLines.map((line, i) => { const isAdd = line.startsWith('+'); const isDel = line.startsWith('-'); return (
{isAdd ? '+' : isDel ? '-' : ' '} {line.slice(1) || ' '}
); })} {lines.length > 8 && ( )}
); } function renderShell(data: Extract) { const output = data.stdout || data.stderr || ''; const lines = output.split('\n'); const displayLines = lines.slice(0, detailsExpanded ? 25 : 5); const isError = data.exitCode !== 0; return (
{truncate(data.command, 50)} {isError ? `exit ${data.exitCode}` : 'ok'}
{output && (
                            {displayLines.join('\n')}
                        
{lines.length > 5 && ( )}
)}
); } function renderSearch(data: Extract) { const matches = data.matches.slice(0, detailsExpanded ? 15 : 5); return (
{data.pattern} {data.totalMatches} matches{data.truncated && '+'}
{matches.map((m, i) => (
{getShortPath(m.file)} {m.line > 0 && :{m.line}} {m.content && (
{m.content}
)}
))} {data.matches.length > 5 && ( )}
); } function renderFile(data: Extract) { const OpIcon = { read: FileText, write: FileEdit, create: FilePlus, delete: Trash2 }[ data.operation ]; const opColors = { read: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600', write: 'bg-amber-100 dark:bg-amber-900/30 text-amber-600', create: 'bg-green-100 dark:bg-green-900/30 text-green-600', delete: 'bg-red-100 dark:bg-red-900/30 text-red-600', }[data.operation]; return (
{data.operation} {getShortPath(data.path)} {data.lineCount !== undefined && ( {data.lineCount} lines )}
); } // ========================================================================= // Render functions for generating preview from toolArgs (no displayData) // ========================================================================= function renderBashResult(_command: string) { // Extract and parse bash result from tool result let stdout = ''; let stderr = ''; let exitCode: number | undefined; let duration: number | undefined; if (toolResult && typeof toolResult === 'object') { const result = toolResult as Record; if (result.content && Array.isArray(result.content)) { const textContent = result.content .filter( (p: unknown) => typeof p === 'object' && p !== null && (p as Record).type === 'text' ) .map((p: unknown) => (p as { text?: string }).text || '') .join('\n'); // Try to parse as JSON bash result try { const parsed = JSON.parse(textContent); if (typeof parsed === 'object' && parsed !== null) { stdout = parsed.stdout || ''; stderr = parsed.stderr || ''; exitCode = typeof parsed.exit_code === 'number' ? parsed.exit_code : undefined; duration = typeof parsed.duration === 'number' ? parsed.duration : undefined; } } catch { // Not JSON, treat as plain output stdout = textContent; } } } const output = stdout || stderr; if (!output && exitCode === undefined) return null; const lines = output.split('\n').filter((l) => l.trim()); const displayLines = lines.slice(0, detailsExpanded ? 25 : 5); const isError = exitCode !== undefined && exitCode !== 0; return (
{/* Status bar */}
{exitCode !== undefined && ( {isError ? `exit ${exitCode}` : 'success'} )} {duration !== undefined && ( {duration}ms )}
{/* Output */} {output && (
                            {displayLines.join('\n')}
                        
{lines.length > 5 && ( )}
)} {/* Stderr if present and different from stdout */} {stderr && stderr !== stdout && (
stderr
                            {stderr}
                        
)}
); } function renderEditResult(_filePath: string, oldString: string, newString: string) { return (
{oldString .split('\n') .slice(0, detailsExpanded ? 15 : 3) .map((line, i) => (
- {line || ' '}
))} {newString .split('\n') .slice(0, detailsExpanded ? 15 : 3) .map((line, i) => (
+ {line || ' '}
))} {(oldString.split('\n').length > 3 || newString.split('\n').length > 3) && ( )}
); } function renderWriteResult(filePath: string, content: string) { return (
); } function renderReadResult(filePath: string) { // Extract content from tool result let content = ''; if (toolResult && typeof toolResult === 'object') { const result = toolResult as Record; if (result.content && Array.isArray(result.content)) { content = result.content .filter( (p: unknown) => typeof p === 'object' && p !== null && (p as Record).type === 'text' ) .map((p: unknown) => (p as { text?: string }).text || '') .join('\n'); } } if (!content) return null; return (
); } function renderGenericResult() { // Extract text from result let resultText = ''; if (toolResult && typeof toolResult === 'object') { const result = toolResult as Record; if (result.content && Array.isArray(result.content)) { resultText = result.content .filter( (p: unknown) => typeof p === 'object' && p !== null && (p as Record).type === 'text' ) .map((p: unknown) => (p as { text?: string }).text || '') .join('\n'); } } if (!resultText) return null; const lines = resultText.split('\n'); const displayLines = lines.slice(0, detailsExpanded ? 20 : 5); return (
                    {displayLines.join('\n')}
                
{lines.length > 5 && ( )}
); } }