fix(chat-ui): interleave thoughts + tools chronologically; per-thought pills

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
This commit is contained in:
2026-04-30 23:42:19 -07:00
parent c77f3fbc7f
commit 41f5f02c68

View File

@@ -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 && <ThinkingBubble thoughts={msg.thoughts} />}
{!isUser && msg.toolEvents && msg.toolEvents.length > 0 && (
<ToolEventList events={msg.toolEvents} />
{!isUser && msg.timeline && msg.timeline.length > 0 && (
<Timeline entries={msg.timeline} />
)}
{(msg.content || isUser) && (
<div style={{
@@ -207,33 +206,60 @@ function MessageBubble({ msg }: { msg: Message }) {
}
/**
* Renders the chronological tool tray for an assistant turn. Adjacent
* runs of the same tool name collapse into a single pill with a "×N"
* counter, so a 12-call shell.exec debugging streak doesn't fill the
* viewport. Click to expand the run into individual pills.
* Renders the chronological turn timeline: thoughts as their own
* collapsed pills, tool calls grouped by adjacent runs of the same
* name with a ×N counter. The flow visually mirrors what actually
* happened: thought → tools → thought → tools → ... → final summary.
*/
function ToolEventList({ events }: { events: ToolEvent[] }) {
const groups: Array<{ name: string; events: ToolEvent[] }> = [];
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<Extract<TimelineEntry, { kind: "tool" }>> };
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 (
<div style={{ marginBottom: 6 }}>
{groups.map((g, i) => <ToolGroup key={i} group={g} />)}
{items.map((item, i) =>
item.kind === "thought" ? (
<ThinkingBubble key={i} thoughts={item.text} />
) : (
<TimelineToolGroup key={i} name={item.name} entries={item.entries} />
)
)}
</div>
);
}
function ToolGroup({ group }: { group: { name: string; events: ToolEvent[] } }) {
function TimelineToolGroup({
name,
entries,
}: {
name: string;
entries: Array<Extract<TimelineEntry, { kind: "tool" }>>;
}) {
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) => <ToolBubble key={i} event={ev} />)}
{entries.map((e, i) => (
<ToolBubble key={i} event={{ name: e.name, status: e.status, result: e.result }} />
))}
{expanded && count > 1 && (
<button
type="button"
@@ -262,9 +288,7 @@ function ToolGroup({ group }: { group: { name: string; events: ToolEvent[] } })
width: "auto",
}}
>
<span style={{ fontFamily: "ui-monospace, monospace" }}>
{group.name}
</span>
<span style={{ fontFamily: "ui-monospace, monospace" }}>{name}</span>
<span style={{ color: "#a09a90" }}>×{count}</span>
<span style={{ marginLeft: "auto", color: allDone ? "#2e7d32" : "#a09a90" }}>
{allDone ? "✓" : "…"}
@@ -532,16 +556,18 @@ export function ChatPanel() {
return next;
});
} else if (ev.type === "thinking" && ev.text) {
// Accumulate reasoning narration on the in-flight
// assistant message. The renderer collapses it by
// default and shows the latest sentence as a pill.
// Each thinking event from the server is one round of the
// model's reasoning. Push as a separate timeline entry so
// the renderer can show it as its own collapsed pill
// 12 rounds become 12 small pills the user can each
// expand independently, not one giant blob.
setMessages((prev) => {
const next = [...prev];
if (msgIndex >= 0 && next[msgIndex]) {
const existing = next[msgIndex].thoughts ?? "";
const tl = next[msgIndex].timeline ?? [];
next[msgIndex] = {
...next[msgIndex],
thoughts: existing + ev.text,
timeline: [...tl, { kind: "thought", text: ev.text }],
};
}
return next;
@@ -550,10 +576,10 @@ export function ChatPanel() {
setMessages((prev) => {
const next = [...prev];
if (msgIndex >= 0 && next[msgIndex]) {
const existing = next[msgIndex].toolEvents ?? [];
const tl = next[msgIndex].timeline ?? [];
next[msgIndex] = {
...next[msgIndex],
toolEvents: [...existing, { name: ev.name, status: "running" }],
timeline: [...tl, { kind: "tool", name: ev.name, status: "running" }],
};
}
return next;
@@ -562,16 +588,22 @@ export function ChatPanel() {
setMessages((prev) => {
const next = [...prev];
if (msgIndex >= 0 && next[msgIndex]) {
const events = next[msgIndex].toolEvents ?? [];
const tl = next[msgIndex].timeline ?? [];
// Walk backward to the most recent matching running
// tool entry and mark it done. Avoids cross-matching
// earlier same-named entries.
let updated = false;
const newEvents = [...events].reverse().map((t) => {
if (!updated && t.name === ev.name && t.status === "running") {
const newTl: TimelineEntry[] = [];
for (let i = tl.length - 1; i >= 0; i--) {
const e = tl[i];
if (!updated && e.kind === "tool" && e.name === ev.name && e.status === "running") {
newTl.unshift({ ...e, status: "done", result: ev.result });
updated = true;
return { ...t, status: "done" as const, result: ev.result };
} else {
newTl.unshift(e);
}
return t;
}).reverse();
next[msgIndex] = { ...next[msgIndex], toolEvents: newEvents };
}
next[msgIndex] = { ...next[msgIndex], timeline: newTl };
}
return next;
});