From 41f5f02c68a49f5faeb92468c496abcfe92a0639 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Thu, 30 Apr 2026 23:42:19 -0700 Subject: [PATCH] fix(chat-ui): interleave thoughts + tools chronologically; per-thought pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bug: 12 rounds of model thinking were concatenated into one giant msg.thoughts string and rendered as a single ThinkingBubble blob. User saw a wall of meta-narrated reasoning ("Okay, here's my interpretation of those thoughts..." x12) with no visual breaks between rounds. Replace separate thoughts/toolEvents fields on Message with a single chronological timeline of mixed entries: type TimelineEntry = | { kind: "thought"; text: string } | { kind: "tool"; name: string; status; result? } Each thought event from the SSE stream becomes its own timeline entry and renders as its own collapsed ThinkingBubble pill. Adjacent same- named tool entries still collapse into a ×N TimelineToolGroup. Visual flow now matches what actually happened in the turn: [thought pill] [dev_server.list ✓] [thought pill] [dev_server.stop ×2 ✓] [thought pill] [shell.exec ×2 ✓] [final summary text] Each pill is independently expandable, so a user who wants to read the model's reasoning for round 7 can click round 7 — they don't have to scroll through a single 4000-char blob. Made-with: Cursor --- components/vibn-chat/chat-panel.tsx | 130 +++++++++++++++++----------- 1 file changed, 81 insertions(+), 49 deletions(-) diff --git a/components/vibn-chat/chat-panel.tsx b/components/vibn-chat/chat-panel.tsx index e38c9c82..d93ebea1 100644 --- a/components/vibn-chat/chat-panel.tsx +++ b/components/vibn-chat/chat-panel.tsx @@ -32,23 +32,23 @@ interface Message { toolName?: string; createdAt?: string; /** - * First-person reasoning narration streamed alongside tool calls. - * Rendered as collapsed italic text above the message bubble; the - * user can expand for the full chain of thought. Discarded on - * persistence (we pay tokens regardless, but the bytes aren't - * worth keeping in PG). + * Chronological turn timeline interleaving the model's thinking + * narration and the tool calls it fired. Rendered as a stack of + * pills INSIDE the bubble above the final text content, so the + * user sees the actual flow: + * [thought] [tool ×N] [thought] [tool] ... [summary] + * Each thought is its own collapsed pill (click to expand); + * adjacent runs of the same tool name collapse into one pill + * with a ×N counter. The final assistant text is rendered + * separately, below the timeline. */ - thoughts?: string; - /** - * Tool calls executed during this assistant turn, in chronological - * order. Rendered as pills INSIDE the bubble above the text content - * so the final summary lands where the user's eye naturally goes - * (the bottom of the bubble) instead of being buried above the - * tool tray. - */ - toolEvents?: ToolEvent[]; + timeline?: TimelineEntry[]; } +type TimelineEntry = + | { kind: "thought"; text: string } + | { kind: "tool"; name: string; status: "running" | "done"; result?: string }; + interface ToolEvent { name: string; status: "running" | "done"; @@ -180,9 +180,8 @@ function MessageBubble({ msg }: { msg: Message }) { display: "flex", flexDirection: "column", }}> - {!isUser && msg.thoughts && } - {!isUser && msg.toolEvents && msg.toolEvents.length > 0 && ( - + {!isUser && msg.timeline && msg.timeline.length > 0 && ( + )} {(msg.content || isUser) && (
= []; - for (const ev of events) { - const last = groups[groups.length - 1]; - if (last && last.name === ev.name) last.events.push(ev); - else groups.push({ name: ev.name, events: [ev] }); +function Timeline({ entries }: { entries: TimelineEntry[] }) { + // Walk the entries and emit a renderable list. Adjacent same-named + // tool entries get bundled into a TimelineToolGroup; everything + // else passes through as-is. + type Item = + | { kind: "thought"; text: string } + | { kind: "toolGroup"; name: string; entries: Array> }; + const items: Item[] = []; + for (const e of entries) { + if (e.kind === "thought") { + items.push({ kind: "thought", text: e.text }); + } else { + const last = items[items.length - 1]; + if (last && last.kind === "toolGroup" && last.name === e.name) { + last.entries.push(e); + } else { + items.push({ kind: "toolGroup", name: e.name, entries: [e] }); + } + } } return (
- {groups.map((g, i) => )} + {items.map((item, i) => + item.kind === "thought" ? ( + + ) : ( + + ) + )}
); } -function ToolGroup({ group }: { group: { name: string; events: ToolEvent[] } }) { +function TimelineToolGroup({ + name, + entries, +}: { + name: string; + entries: Array>; +}) { const [expanded, setExpanded] = useState(false); - const count = group.events.length; - const allDone = group.events.every(e => e.status === "done"); + const count = entries.length; + const allDone = entries.every((e) => e.status === "done"); if (count === 1 || expanded) { return ( <> - {group.events.map((ev, i) => )} + {entries.map((e, i) => ( + + ))} {expanded && count > 1 && (