import { useEffect, useMemo, useRef } from 'react'; import type { Chat, Message } from '@/api/types'; import { MessageBubble } from './MessageBubble'; import { ToolCallGroup } from './ToolCallGroup'; import { ToolCallLine, type ToolRun } from './ToolCallLine'; import { AskUserInputCard } from './AskUserInputCard'; import { RequestReadAccessCard } from './RequestReadAccessCard'; interface Props { messages: Message[]; sessionChats?: Chat[]; } // v1.8.2: pre-render units. The single linear `messages` array gets walked // into a render-time list where each tool_call is a first-class item and // tool_result messages are folded onto their matching tool_run by id. // Batch 9.7: tool_run carries chat_id so AskUserInputCard can post the // answer without threading the chat id through MessageList's parent. type RenderItem = | { kind: 'message'; message: Message; capHitInfo?: { position: number; isLatest: boolean } } | { kind: 'tool_run'; run: ToolRun; key: string; chatId: string } | { kind: 'tool_group'; runs: ToolRun[]; key: string }; const GROUP_THRESHOLD = 3; function isCapHitSentinel(m: Message): boolean { return m.role === 'system' && m.metadata?.kind === 'cap_hit'; } // First pass: walk messages chronologically, expanding assistant tool_calls // into per-call run items and folding tool_result messages onto their // matching runs. Tool messages themselves never produce a render item. // Assistant messages produce a text render item only when they have text; // pure tool-call messages are "transparent" so consecutive tool runs can // still group across them. function flatten(messages: Message[]): RenderItem[] { const items: RenderItem[] = []; const runsByCallId = new Map(); for (const m of messages) { if (m.role === 'tool') { if (m.tool_results) { const run = runsByCallId.get(m.tool_results.tool_call_id); if (run) run.result = m.tool_results; } continue; } const hasToolCalls = m.tool_calls != null && m.tool_calls.length > 0; // v1.13.7: trim before checking. AI SDK v6 streaming occasionally emits a // leading "\n" text-delta on tool-call-only turns, which used to flow into // messages.content with length=1 and render an empty bubble + ActionRow // between each tool call. Whitespace-only content has no visible payload, // so treat it as no-content. const hasText = m.content.trim().length > 0; if (m.role === 'assistant' && hasToolCalls) { if (hasText || m.status === 'streaming') { items.push({ kind: 'message', message: m }); } for (const tc of m.tool_calls!) { const run: ToolRun = { call: tc, result: null }; runsByCallId.set(tc.id, run); items.push({ kind: 'tool_run', run, key: tc.id, chatId: m.chat_id }); } continue; } items.push({ kind: 'message', message: m }); } return items; } // Second pass: collapse runs of >=GROUP_THRESHOLD consecutive tool_run items // of the same tool name into a single tool_group. Any other render item // (text bubble, sentinel, user message) breaks the chain. // Batch 9.7: ask_user_input never groups — each pause has its own card so // grouping would render them as collapsed ToolCallLines which can't surface // the interactive form. function group(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' || name === 'request_read_access') { // v1.13.17: same rationale as ask_user_input — grouping would collapse // the interactive pause card into a non-actionable ToolCallLine. 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; chatId: 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; } // Third pass: number cap-hit sentinels (1-indexed) and mark the latest. // CapHitSentinel uses position to compute the "N continues remaining" // tooltip, and isLatest to gate the Continue button (only the most recent // sentinel is actionable). function stampCapHits(items: RenderItem[]): RenderItem[] { const totalCapHits = items.reduce( (n, it) => n + (it.kind === 'message' && isCapHitSentinel(it.message) ? 1 : 0), 0, ); if (totalCapHits === 0) return items; let index = 0; return items.map((it) => { if (it.kind !== 'message' || !isCapHitSentinel(it.message)) return it; index += 1; return { ...it, capHitInfo: { position: index, isLatest: index === totalCapHits }, }; }); } export function MessageList({ messages, sessionChats }: Props) { const endRef = useRef(null); const renderItems = useMemo(() => stampCapHits(group(flatten(messages))), [messages]); useEffect(() => { endRef.current?.scrollIntoView({ block: 'end' }); }, [messages]); if (messages.length === 0) { return (
Send a message to start.
); } return (
{renderItems.map((item) => { if (item.kind === 'message') { return ( ); } if (item.kind === 'tool_run') { if (item.run.call.name === 'ask_user_input') { return ( ); } if (item.run.call.name === 'request_read_access') { return ( ); } return ; } return ; })}
); }