import { useState, useEffect, useRef } from 'react'; import type { SessionEvent, EventPayload } from '../types'; import { esc, truncate, cn, extractEventText, isConversationClearedStatus } from '../lib/utils'; // ============================================================ // Tool Trace State // ============================================================ interface TraceHost { id: string; kind: 'assistant' | 'orphan'; assistantContent: string; entryKinds: ('use' | 'result')[]; } interface ToolTraceState { nextHostId: number; activeHostId: string | null; hosts: TraceHost[]; } function createTraceState(): ToolTraceState { return { nextHostId: 1, activeHostId: null, hosts: [] }; } function addAssistantHost(state: ToolTraceState, content: string): { state: ToolTraceState; host: TraceHost } { const host: TraceHost = { id: `trace-${state.nextHostId}`, kind: 'assistant', assistantContent: content, entryKinds: [], }; return { state: { nextHostId: state.nextHostId + 1, activeHostId: host.id, hosts: [...state.hosts, host], }, host, }; } function clearActiveHost(state: ToolTraceState): ToolTraceState { if (!state.activeHostId) return state; return { ...state, activeHostId: null }; } function addTraceEntry( state: ToolTraceState, entryKind: 'use' | 'result', ): { state: ToolTraceState; host: TraceHost; createdHost: TraceHost | null; } { let host = state.hosts.find(h => h.id === state.activeHostId); let createdHost: TraceHost | null = null; if (!host) { createdHost = { id: `trace-${state.nextHostId}`, kind: 'orphan', assistantContent: '', entryKinds: [], }; host = createdHost; } const updatedHost = { ...host, entryKinds: [...host.entryKinds, entryKind] }; const newHosts = state.hosts.map(h => (h.id === updatedHost.id ? updatedHost : h)); if (createdHost) newHosts.push(createdHost); return { state: { nextHostId: createdHost ? state.nextHostId + 1 : state.nextHostId, activeHostId: (createdHost || host).id, hosts: newHosts, }, host: updatedHost, createdHost, }; } // ============================================================ // Message Types // ============================================================ interface UserMessage { kind: 'user'; content: string; } interface AssistantMessage { kind: 'assistant'; content: string; traceEntries: TraceEntry[]; traceExpanded: boolean; traceId: string; } interface TraceEntry { entryKind: 'use' | 'result'; toolName?: string; toolInput?: unknown; content?: string; output?: string; isError?: boolean; } interface SystemMessage { kind: 'system'; content: string; } interface PermissionMessage { kind: 'permission'; requestId: string; toolName: string; toolInput: unknown; description: string; } interface AskUserMessage { kind: 'ask_user'; requestId: string; questions: import('../types').Question[]; description: string; } interface PlanMessage { kind: 'plan'; requestId: string; planContent: string; description: string; } interface LoadingMessage { kind: 'loading'; verb: string; startTime: number; } type DisplayMessage = | UserMessage | AssistantMessage | SystemMessage | PermissionMessage | AskUserMessage | PlanMessage | LoadingMessage; // ============================================================ // Spinner // ============================================================ const SPINNER_FRAMES = ['·', '✢', '✱', '✶', '✻', '✽']; const SPINNER_CYCLE = [...SPINNER_FRAMES, ...SPINNER_FRAMES.slice().reverse()]; const SPINNER_VERBS = [ 'Accomplishing', 'Baking', 'Calculating', 'Clauding', 'Cogitating', 'Computing', 'Considering', 'Contemplating', 'Cooking', 'Crafting', 'Creating', 'Crunching', 'Deliberating', 'Doing', 'Effecting', 'Generating', 'Hatching', 'Ideating', 'Imagining', 'Inferring', 'Manifesting', 'Mulling', 'Pondering', 'Processing', 'Ruminating', 'Simmering', 'Synthesizing', 'Thinking', 'Tinkering', 'Working', ]; // ============================================================ // EventStream Component // ============================================================ interface EventStreamProps { messages: DisplayMessage[]; onApprovePermission: (requestId: string) => void; onRejectPermission: (requestId: string) => void; onSubmitAnswers: ( requestId: string, answers: Record, questions: import('../types').Question[], ) => void; onSubmitPlanResponse: (requestId: string, value: string, feedback?: string) => void; } export function EventStream({ messages, onApprovePermission, onRejectPermission, onSubmitAnswers, onSubmitPlanResponse, }: EventStreamProps) { const scrollRef = useRef(null); useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [messages]); return (
{messages.map((msg, i) => ( ))}
); } function MessageRow({ message, onApprovePermission, onRejectPermission, onSubmitAnswers, onSubmitPlanResponse, }: { message: DisplayMessage; onApprovePermission: (requestId: string) => void; onRejectPermission: (requestId: string) => void; onSubmitAnswers: ( requestId: string, answers: Record, questions: import('../types').Question[], ) => void; onSubmitPlanResponse: (requestId: string, value: string, feedback?: string) => void; }) { switch (message.kind) { case 'user': return ; case 'assistant': return ; case 'system': return ; case 'permission': return ( onApprovePermission(message.requestId)} onReject={() => onRejectPermission(message.requestId)} /> ); case 'ask_user': return ( onSubmitAnswers(message.requestId, answers, message.questions)} onSkip={() => onRejectPermission(message.requestId)} /> ); case 'plan': return ( onSubmitPlanResponse(message.requestId, value, feedback)} /> ); case 'loading': return ; default: return null; } } // ============================================================ // Sub-Components // ============================================================ function UserBubble({ content }: { content: string }) { return (
{esc(content)}
); } function formatAssistantContent(content: string): string { let html = esc(content); // Code blocks html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, _lang, code) => { return `
${code.trim()}
`; }); // Inline code html = html.replace(/`([^`]+)`/g, '$1'); // Bold html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); return html; } function AssistantBubble({ content, traceEntries }: { content: string; traceEntries: TraceEntry[] }) { const [expanded, setExpanded] = useState(false); return (
{content && (
)} {traceEntries.length > 0 && (
{expanded && (
{traceEntries.map((entry, i) => ( ))}
)}
)}
); } function ToolCard({ entry }: { entry: TraceEntry }) { const [expanded, setExpanded] = useState(false); if (entry.entryKind === 'use') { const inputStr = typeof entry.toolInput === 'string' ? entry.toolInput : JSON.stringify(entry.toolInput, null, 2); return (
setExpanded(!expanded)} >
{entry.toolName || 'tool'}
{expanded && (
            {truncate(inputStr, 2000)}
          
)}
); } const contentStr = typeof entry.output === 'string' ? entry.output : JSON.stringify(entry.output, null, 2); return (
setExpanded(!expanded)} >
{entry.isError ? '✕' : '✓'} {entry.isError ? 'Error' : 'Result'}
{expanded && (
          {truncate(contentStr, 2000)}
        
)}
); } function SystemBubble({ content }: { content: string }) { return (
{esc(content)}
); } function PermissionPrompt({ requestId, toolName, toolInput, description, onApprove, onReject, }: { requestId: string; toolName: string; toolInput: unknown; description: string; onApprove: () => void; onReject: () => void; }) { const inputStr = typeof toolInput === 'string' ? toolInput : JSON.stringify(toolInput, null, 2); return (
Permission Request
{description &&
{esc(description)}
}
{esc(toolName)}
{toolName !== 'AskUserQuestion' && (
          {truncate(inputStr, 500)}
        
)}
); } function AskUserPanel({ questions, description, onSubmit, onSkip, }: { requestId: string; questions: import('../types').Question[]; description: string; onSubmit: (answers: Record) => void; onSkip: () => void; }) { const [answers, setAnswers] = useState>({}); const [otherTexts, setOtherTexts] = useState>({}); const handleSelect = (qIdx: number, oIdx: number, multiSelect: boolean) => { if (multiSelect) { const current = (answers[qIdx] as number[]) || []; const next = current.includes(oIdx) ? current.filter(i => i !== oIdx) : [...current, oIdx]; setAnswers({ ...answers, [qIdx]: next }); } else { setAnswers({ ...answers, [qIdx]: oIdx }); } }; const handleOtherSubmit = (qIdx: number) => { const text = otherTexts[qIdx]?.trim(); if (!text) return; setAnswers({ ...answers, [qIdx]: text }); setOtherTexts({ ...otherTexts, [qIdx]: '' }); }; const handleSubmit = () => { const mapped: Record = {}; for (const [qIdx, val] of Object.entries(answers)) { const q = questions[parseInt(qIdx, 10)]; if (!q) continue; if (typeof val === 'number') { mapped[qIdx] = q.options?.[val]?.label || String(val); } else if (Array.isArray(val)) { mapped[qIdx] = val.map(i => q.options?.[i]?.label || String(i)); } else { mapped[qIdx] = val; } } onSubmit(mapped); }; if (questions.length <= 1) { const q = questions[0] || { question: description, options: [], multiSelect: false }; const selectedIdx = answers[0]; const multiSelect = q.multiSelect || false; return (
{esc(description || q.question || 'Question')}
{(q.options || []).map((opt, j) => { const isSelected = multiSelect ? ((answers[0] as number[]) || []).includes(j) : selectedIdx === j; return ( ); })}
setOtherTexts({ ...otherTexts, [0]: e.target.value })} placeholder="Other..." className="flex-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-brand focus:outline-none" onKeyDown={e => e.key === 'Enter' && handleOtherSubmit(0)} />
); } // Multiple questions — tab layout const [activeTab, setActiveTab] = useState(0); return (
{esc(description || 'Questions')}
{questions.map((q, i) => ( ))}
{questions[activeTab] && ( setOtherTexts({ ...otherTexts, [qIdx]: text })} onOtherSubmit={handleOtherSubmit} /> )}
{activeTab + 1} / {questions.length}
); } function QuestionTab({ question, qIdx, answers, otherTexts, onSelect, onOtherTextChange, onOtherSubmit, }: { question: import('../types').Question; qIdx: number; answers: Record; otherTexts: Record; onSelect: (qIdx: number, oIdx: number, multiSelect: boolean) => void; onOtherTextChange: (qIdx: number, text: string) => void; onOtherSubmit: (qIdx: number) => void; }) { const multiSelect = question.multiSelect || false; return (
{esc(question.question)}
{(question.options || []).map((opt, j) => { const isSelected = multiSelect ? ((answers[qIdx] as number[]) || []).includes(j) : answers[qIdx] === j; return ( ); })}
onOtherTextChange(qIdx, e.target.value)} placeholder="Other..." className="flex-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-brand focus:outline-none" onKeyDown={e => e.key === 'Enter' && onOtherSubmit(qIdx)} />
); } function PlanPanel({ planContent, description, onSubmit, }: { requestId: string; planContent: string; description: string; onSubmit: (value: string, feedback?: string) => void; }) { const [selected, setSelected] = useState(null); const [feedback, setFeedback] = useState(''); const isEmpty = !planContent || !planContent.trim(); const handleSubmit = () => { if (!selected) return; onSubmit(selected, selected === 'no' ? feedback : undefined); }; return (
{isEmpty ? 'Exit plan mode?' : 'Ready to code?'}
{!isEmpty && (
)}
{isEmpty ? ( <> setSelected('yes-default')} label="Yes" /> setSelected('no')} label="No" /> ) : ( <> setSelected('yes-accept-edits')} label="Yes, auto-accept edits" desc="Approve plan and auto-accept file edits" /> setSelected('yes-default')} label="Yes, manually approve edits" desc="Approve plan but confirm each edit" /> setSelected('no')} label="No, keep planning" desc="Provide feedback to refine the plan" /> )}
{selected === 'no' && (