import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react'; import { MessageBubble, type MessageActions } from '@/components/MessageBubble'; import { ToolCallGroup } from '@/components/ToolCallGroup'; import { ToolCallLine, type ToolRun } from '@/components/ToolCallLine'; import { AskUserInputCard } from '@/components/AskUserInputCard'; import { wireToolCallToRun, type CoderToolCallWire } from '@/lib/coder-tools'; import type { Message } from '@/api/types'; export interface CoderMessageWire { id: string; role: 'user' | 'assistant' | 'system'; content: string; status?: 'streaming' | 'complete' | 'failed'; reasoning_text?: string; tool_calls?: CoderToolCallWire[]; } export interface CoderToolMessageWire { id: string; role: 'tool'; tool_results: { tool_call_id: string; output: unknown; truncated?: boolean; error?: string; }; } export type CoderTimelineWire = CoderMessageWire | CoderToolMessageWire; function isToolMessage(m: CoderTimelineWire): m is CoderToolMessageWire { return m.role === 'tool'; } type RenderItem = | { kind: 'message'; message: CoderMessageWire } | { kind: 'tool_run'; run: ToolRun; key: string } | { kind: 'tool_group'; runs: ToolRun[]; key: string }; const GROUP_THRESHOLD = 3; const SCROLL_THRESHOLD_PX = 150; function flattenCoderMessages(messages: CoderTimelineWire[]): RenderItem[] { const items: RenderItem[] = []; const runsByCallId = new Map(); for (const m of messages) { if (isToolMessage(m)) { const run = runsByCallId.get(m.tool_results.tool_call_id); if (run) { run.result = { tool_call_id: m.tool_results.tool_call_id, output: m.tool_results.output, truncated: m.tool_results.truncated ?? false, ...(m.tool_results.error ? { error: m.tool_results.error } : {}), }; } continue; } if (m.role === 'user' || m.role === 'system') { items.push({ kind: 'message', message: m }); continue; } const hasToolCalls = (m.tool_calls?.length ?? 0) > 0; const hasText = m.content.trim().length > 0; const hasReasoning = (m.reasoning_text?.trim().length ?? 0) > 0; // External agents persist tool calls + final answer on one row. Render tools // before the answer text so the timeline matches BooChat (tools, then reply). const externalCombined = hasToolCalls && (hasText || hasReasoning); if (externalCombined) { if (hasReasoning) { items.push({ kind: 'message', message: { ...m, content: '', reasoning_text: m.reasoning_text }, }); } for (const tc of m.tool_calls!) { const run = wireToolCallToRun(tc); runsByCallId.set(tc.id, run); items.push({ kind: 'tool_run', run, key: tc.id }); } if (hasText || m.status === 'streaming') { items.push({ kind: 'message', message: { ...m, reasoning_text: undefined }, }); } continue; } // Native inference: separate assistant rows per step — mirror MessageList. if (hasText || hasReasoning || m.status === 'streaming') { items.push({ kind: 'message', message: m }); } if (hasToolCalls) { for (const tc of m.tool_calls!) { const run = wireToolCallToRun(tc); runsByCallId.set(tc.id, run); items.push({ kind: 'tool_run', run, key: tc.id }); } } } return items; } function groupToolRuns(items: RenderItem[]): RenderItem[] { const out: RenderItem[] = []; let i = 0; while (i < items.length) { const item = items[i]!; if (item.kind !== 'tool_run') { out.push(item); i += 1; continue; } const name = item.run.call.name; if (name === 'ask_user_input') { out.push(item); i += 1; continue; } let j = i + 1; while ( j < items.length && items[j]!.kind === 'tool_run' && (items[j] as { kind: 'tool_run'; run: ToolRun }).run.call.name === name ) { j += 1; } const run = items.slice(i, j) as Array<{ kind: 'tool_run'; run: ToolRun; key: string }>; if (run.length >= GROUP_THRESHOLD) { out.push({ kind: 'tool_group', runs: run.map((r) => r.run), key: `group-${run[0]!.key}` }); } else { for (const r of run) out.push(r); } i = j; } return out; } interface Props { messages: CoderTimelineWire[]; chatId?: string; footer?: ReactNode; actions?: MessageActions; // write-edit-robustness #4: assistant message ids that have a worktree // checkpoint. The "Restore to here" control renders only on these. checkpointMessageIds?: Set; // write-edit-robustness #4: suppress restore during an active turn (mirrors // composer gating in CoderPane). restoreDisabled?: boolean; } const CODER_HIDDEN_ACTIONS: ('fork' | 'delete')[] = ['fork']; export function CoderMessageList({ messages, chatId, footer, actions, checkpointMessageIds, restoreDisabled, }: Props) { const endRef = useRef(null); const scrollRef = useRef(null); const isNearBottomRef = useRef(true); const renderItems = useMemo( () => groupToolRuns(flattenCoderMessages(messages)), [messages], ); const handleScroll = useCallback(() => { const el = scrollRef.current; if (!el) return; isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD_PX; }, []); useEffect(() => { if (isNearBottomRef.current) { endRef.current?.scrollIntoView({ block: 'end' }); } }, [messages]); if (messages.length === 0) { return null; } return (
{renderItems.map((item) => { if (item.kind === 'message') { return ( ); } if (item.kind === 'tool_run') { if (item.run.call.name === 'ask_user_input' && chatId) { return ( ); } return ; } return ; })} {footer}
); }