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:
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user