import { useTranslation } from 'react-i18next'; import MarkdownRenderer from '@/components/ui/markdown-renderer.tsx'; import { ActionType, NormalizedEntry, TaskAttempt, ToolStatus, type NormalizedEntryType, } from 'shared/types.ts'; import type { ProcessStartPayload } from '@/types/logs'; import FileChangeRenderer from './FileChangeRenderer'; import { useExpandable } from '@/stores/useExpandableStore'; import { AlertCircle, Bot, Brain, CheckSquare, ChevronDown, Hammer, Edit, Eye, Globe, Plus, Search, Settings, Terminal, User, } from 'lucide-react'; import RawLogText from '../common/RawLogText'; import UserMessage from './UserMessage'; import PendingApprovalEntry from './PendingApprovalEntry'; import { cn } from '@/lib/utils'; type Props = { entry: NormalizedEntry | ProcessStartPayload; expansionKey: string; diffDeletable?: boolean; executionProcessId?: string; taskAttempt?: TaskAttempt; }; type FileEditAction = Extract; type JsonValue = any; const renderJson = (v: JsonValue) => (
{JSON.stringify(v, null, 2)}
); const getEntryIcon = (entryType: NormalizedEntryType) => { const iconSize = 'h-3 w-3'; if (entryType.type === 'user_message') { return ; } if (entryType.type === 'assistant_message') { return ; } if (entryType.type === 'system_message') { return ; } if (entryType.type === 'thinking') { return ; } if (entryType.type === 'error_message') { return ; } if (entryType.type === 'tool_use') { const { action_type, tool_name } = entryType; // Special handling for TODO tools if ( action_type.action === 'todo_management' || (tool_name && (tool_name.toLowerCase() === 'todowrite' || tool_name.toLowerCase() === 'todoread' || tool_name.toLowerCase() === 'todo_write' || tool_name.toLowerCase() === 'todo_read' || tool_name.toLowerCase() === 'todo')) ) { return ; } if (action_type.action === 'file_read') { return ; } else if (action_type.action === 'file_edit') { return ; } else if (action_type.action === 'command_run') { return ; } else if (action_type.action === 'search') { return ; } else if (action_type.action === 'web_fetch') { return ; } else if (action_type.action === 'task_create') { return ; } else if (action_type.action === 'plan_presentation') { return ; } else if (action_type.action === 'tool') { return ; } return ; } return ; }; type ExitStatusVisualisation = 'success' | 'error' | 'pending'; const getStatusIndicator = (entryType: NormalizedEntryType) => { let status_visualisation: ExitStatusVisualisation | null = null; if ( entryType.type === 'tool_use' && entryType.action_type.action === 'command_run' ) { status_visualisation = 'pending'; if (entryType.action_type.result?.exit_status?.type === 'success') { if (entryType.action_type.result?.exit_status?.success) { status_visualisation = 'success'; } else { status_visualisation = 'error'; } } else if ( entryType.action_type.result?.exit_status?.type === 'exit_code' ) { if (entryType.action_type.result?.exit_status?.code === 0) { status_visualisation = 'success'; } else { status_visualisation = 'error'; } } } // If pending, should be a pulsing primary-foreground const colorMap: Record = { success: 'bg-green-300', error: 'bg-red-300', pending: 'bg-primary-foreground/50', }; if (!status_visualisation) return null; return (
{status_visualisation === 'pending' && (
)}
); }; /********************** * Helper definitions * **********************/ const shouldRenderMarkdown = (entryType: NormalizedEntryType) => entryType.type === 'assistant_message' || entryType.type === 'system_message' || entryType.type === 'thinking' || entryType.type === 'tool_use'; const getContentClassName = (entryType: NormalizedEntryType) => { const base = ' whitespace-pre-wrap break-words'; if ( entryType.type === 'tool_use' && entryType.action_type.action === 'command_run' ) return `${base} font-mono`; // Keep content-only styling — no bg/padding/rounded here. if (entryType.type === 'error_message') return `${base} font-mono text-destructive`; if (entryType.type === 'thinking') return `${base} opacity-60`; if ( entryType.type === 'tool_use' && (entryType.action_type.action === 'todo_management' || (entryType.tool_name && ['todowrite', 'todoread', 'todo_write', 'todo_read', 'todo'].includes( entryType.tool_name.toLowerCase() ))) ) return `${base} font-mono text-zinc-800 dark:text-zinc-200`; if ( entryType.type === 'tool_use' && entryType.action_type.action === 'plan_presentation' ) return `${base} text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/20 px-3 py-2 border-l-4 border-blue-400`; return base; }; /********************* * Unified card * *********************/ type CardVariant = 'system' | 'error'; const MessageCard: React.FC<{ children: React.ReactNode; variant: CardVariant; expanded?: boolean; onToggle?: () => void; }> = ({ children, variant, expanded, onToggle }) => { const frameBase = 'border px-3 py-2 w-full cursor-pointer bg-[hsl(var(--card))] border-[hsl(var(--border))]'; const systemTheme = 'border-400/40 text-zinc-500'; const errorTheme = 'border-red-400/40 bg-red-50 dark:bg-[hsl(var(--card))] text-[hsl(var(--foreground))]'; return (
{children}
{onToggle && ( )}
); }; /************************ * Collapsible container * ************************/ type CollapsibleVariant = 'system' | 'error'; const ExpandChevron: React.FC<{ expanded: boolean; onClick: () => void; variant: CollapsibleVariant; }> = ({ expanded, onClick, variant }) => { const color = variant === 'system' ? 'text-700 dark:text-300' : 'text-red-700 dark:text-red-300'; return ( ); }; const CollapsibleEntry: React.FC<{ content: string; markdown: boolean; expansionKey: string; variant: CollapsibleVariant; contentClassName: string; }> = ({ content, markdown, expansionKey, variant, contentClassName }) => { const multiline = content.includes('\n'); const [expanded, toggle] = useExpandable(`entry:${expansionKey}`, false); const Inner = (
{markdown ? ( ) : ( content )}
); const firstLine = content.split('\n')[0]; const PreviewInner = (
{markdown ? ( ) : ( firstLine )}
); if (!multiline) { return {Inner}; } return expanded ? ( {Inner} ) : ( {PreviewInner} ); }; type ToolStatusAppearance = 'default' | 'denied' | 'timed_out'; const PLAN_APPEARANCE: Record< ToolStatusAppearance, { border: string; headerBg: string; headerText: string; contentBg: string; contentText: string; } > = { default: { border: 'border-blue-400/40', headerBg: 'bg-blue-50 dark:bg-blue-950/20', headerText: 'text-blue-700 dark:text-blue-300', contentBg: 'bg-blue-50 dark:bg-blue-950/20', contentText: 'text-blue-700 dark:text-blue-300', }, denied: { border: 'border-red-400/40', headerBg: 'bg-red-50 dark:bg-red-950/20', headerText: 'text-red-700 dark:text-red-300', contentBg: 'bg-red-50 dark:bg-red-950/10', contentText: 'text-red-700 dark:text-red-300', }, timed_out: { border: 'border-amber-400/40', headerBg: 'bg-amber-50 dark:bg-amber-950/20', headerText: 'text-amber-700 dark:text-amber-200', contentBg: 'bg-amber-50 dark:bg-amber-950/10', contentText: 'text-amber-700 dark:text-amber-200', }, }; const PlanPresentationCard: React.FC<{ plan: string; expansionKey: string; defaultExpanded?: boolean; statusAppearance?: ToolStatusAppearance; }> = ({ plan, expansionKey, defaultExpanded = false, statusAppearance = 'default', }) => { const { t } = useTranslation('common'); const [expanded, toggle] = useExpandable( `plan-entry:${expansionKey}`, defaultExpanded ); const tone = PLAN_APPEARANCE[statusAppearance]; return (
{expanded && (
)}
); }; const ToolCallCard: React.FC<{ entryType?: Extract; action?: any; expansionKey: string; content?: string; entryContent?: string; highlighted?: boolean; defaultExpanded?: boolean; statusAppearance?: ToolStatusAppearance; forceExpanded?: boolean; }> = ({ entryType, action, expansionKey, content, entryContent, defaultExpanded = false, forceExpanded = false, }) => { const { t } = useTranslation('common'); const at: any = entryType?.action_type || action; const [expanded, toggle] = useExpandable( `tool-entry:${expansionKey}`, defaultExpanded ); const effectiveExpanded = forceExpanded || expanded; const label = at?.action === 'command_run' ? 'Ran' : entryType?.tool_name || at?.tool_name || 'Tool'; const isCommand = at?.action === 'command_run'; const inlineText = (entryContent || content || '').trim(); const isSingleLine = inlineText !== '' && !/\r?\n/.test(inlineText); const showInlineSummary = isSingleLine; const hasArgs = at?.action === 'tool' && !!at?.arguments; const hasResult = at?.action === 'tool' && !!at?.result; const output: string | null = isCommand ? (at?.result?.output ?? null) : null; let argsText: string | null = null; if (isCommand) { const fromArgs = typeof at?.arguments === 'string' ? at.arguments : at?.arguments != null ? JSON.stringify(at.arguments, null, 2) : ''; const fallback = (entryContent || content || '').trim(); argsText = (fromArgs || fallback).trim(); } const hasExpandableDetails = isCommand ? Boolean(argsText) || Boolean(output) : hasArgs || hasResult; const HeaderWrapper: React.ElementType = hasExpandableDetails ? 'button' : 'div'; const headerProps = hasExpandableDetails ? { onClick: (e: React.MouseEvent) => { e.preventDefault(); toggle(); }, title: effectiveExpanded ? t('conversation.toolDetailsToggle.hide') : t('conversation.toolDetailsToggle.show'), } : {}; const headerClassName = cn( 'w-full flex items-center gap-1.5 text-left text-secondary-foreground' ); return (
{entryType ? ( {getStatusIndicator(entryType)} {getEntryIcon(entryType)} ) : ( {label} )} {showInlineSummary && ( {inlineText} )} {effectiveExpanded && (
{isCommand ? ( <> {argsText && ( <>
{t('conversation.args')}
{argsText}
)} {output && ( <>
{t('conversation.output')}
)} ) : ( <> {entryType?.action_type.action === 'tool' && ( <>
{t('conversation.args')}
{renderJson(entryType.action_type.arguments)}
{t('conversation.result')}
{entryType.action_type.result?.type.type === 'markdown' && entryType.action_type.result.value && ( )} {entryType.action_type.result?.type.type === 'json' && renderJson(entryType.action_type.result.value)}
)} )}
)}
); }; const LoadingCard = () => { return (
); }; const isPendingApprovalStatus = ( status: ToolStatus ): status is Extract => status.status === 'pending_approval'; const getToolStatusAppearance = (status: ToolStatus): ToolStatusAppearance => { if (status.status === 'denied') return 'denied'; if (status.status === 'timed_out') return 'timed_out'; return 'default'; }; /******************* * Main component * *******************/ function DisplayConversationEntry({ entry, expansionKey, executionProcessId, taskAttempt, }: Props) { const isNormalizedEntry = ( entry: NormalizedEntry | ProcessStartPayload ): entry is NormalizedEntry => 'entry_type' in entry; const isProcessStart = ( entry: NormalizedEntry | ProcessStartPayload ): entry is ProcessStartPayload => 'processId' in entry; if (isProcessStart(entry)) { const toolAction: any = entry.action ?? null; return ( ); } // Handle NormalizedEntry const entryType = entry.entry_type; const isSystem = entryType.type === 'system_message'; const isError = entryType.type === 'error_message'; const isToolUse = entryType.type === 'tool_use'; const isUserMessage = entryType.type === 'user_message'; const isLoading = entryType.type === 'loading'; const isFileEdit = (a: ActionType): a is FileEditAction => a.action === 'file_edit'; if (isUserMessage) { return ( ); } const renderToolUse = () => { if (!isNormalizedEntry(entry)) return null; if (entryType.type !== 'tool_use') return null; const toolEntry = entryType; const status = toolEntry.status; const statusAppearance = getToolStatusAppearance(status); const isPlanPresentation = toolEntry.action_type.action === 'plan_presentation'; const isPendingApproval = status.status === 'pending_approval'; const defaultExpanded = isPendingApproval || isPlanPresentation; const body = (() => { if (isFileEdit(toolEntry.action_type)) { const fileEditAction = toolEntry.action_type as FileEditAction; return (
{fileEditAction.changes.map((change, idx) => ( ))}
); } if (toolEntry.action_type.action === 'plan_presentation') { return ( ); } return ( ); })(); const content =
{body}
; if (isPendingApprovalStatus(status)) { return ( {content} ); } return content; }; if (isToolUse) { return renderToolUse(); } if (isSystem || isError) { return (
); } if (isLoading) { return (
); } return (
{shouldRenderMarkdown(entryType) ? ( ) : isNormalizedEntry(entry) ? ( entry.content ) : ( '' )}
); } export default DisplayConversationEntry;