Show reasoning traces: live Thinking block + persisted disclosure

New reasoning_delta event; traces persist as a display sidecar stripped from provider feeds.
Sources: compat vendors' reasoning_content and Gemini thought summaries (include_thoughts).
Live-verified on GLM via Together and Gemini 3; Gemini tool loops stay healthy.
This commit is contained in:
Rohit C Prasad
2026-07-22 16:15:11 -07:00
committed by Rohit P
parent f1eb652d61
commit 0e8b85e6f3
16 changed files with 421 additions and 67 deletions
+21
View File
@@ -648,6 +648,27 @@ export async function mockApi(page: import("@playwright/test").Page) {
});
return;
}
// A reasoning model's turn: thinking deltas tick in slowly, then the answer —
// the assistant_message carries the full trace like the real engine's payload.
if (/think hard/i.test(msg.text)) {
const thoughts = ["Weighing options. ", "Comparing tradeoffs. ", "Settling it. "];
let tick = 0;
const timer = setInterval(() => {
if (tick < thoughts.length) {
send("reasoning_delta", { text: thoughts[tick] });
tick += 1;
return;
}
clearInterval(timer);
send("assistant_delta", { text: "Decision made." });
send("assistant_message", {
text: "Decision made.",
reasoning: thoughts.join(""),
});
send("turn_done");
}, 120);
return;
}
// A turn that dies on a provider error; the follow-up {type:"retry"} recovers.
if (/fail the turn/i.test(msg.text)) {
send("error", { error: "model unreachable" });
+31
View File
@@ -0,0 +1,31 @@
// Model-layer roadmap item 4 (2026-07-22): reasoning traces. Live turn shows a quiet
// pulsing "Thinking…" disclosure that streams the trace; once the message finalizes the
// trace folds into a collapsed "Thought process" disclosure on the answer bubble.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("thinking streams live, then persists as a collapsed disclosure on the answer", async ({
page,
}) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("think hard about this");
await box.press("Enter");
// Live phase: the Thinking… block is up while deltas tick in; expanding shows the trace.
await expect(page.getByText("Thinking…").first()).toBeVisible({ timeout: 10_000 });
await page.getByTestId("thinking-toggle").click();
await expect(page.getByTestId("thinking-body")).toContainText("Weighing options.");
// Finalized: the answer bubble carries a collapsed "Thought process" disclosure.
await expect(page.getByText("Decision made.").first()).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("Thinking…")).toHaveCount(0);
const toggle = page.getByTestId("thinking-toggle");
await expect(toggle).toHaveText(/Thought process/);
await expect(page.getByTestId("thinking-body")).toHaveCount(0); // collapsed by default
await toggle.click();
await expect(page.getByTestId("thinking-body")).toContainText(
"Weighing options. Comparing tradeoffs. Settling it.",
);
});
+51 -6
View File
@@ -36,7 +36,7 @@ import { InboxItemCard } from "./components/InboxItemCard";
import { isTauri, platformOS, startWindowDrag } from "./tauri";
import { Icon } from "./components/Icon";
import { Sidebar } from "./components/Sidebar";
import { Transcript } from "./components/Transcript";
import { ThinkingBlock, Transcript } from "./components/Transcript";
import { Composer } from "./components/Composer";
import { Markdown } from "./components/Markdown";
import { SearchModal } from "./components/SearchModal";
@@ -162,6 +162,14 @@ export function App() {
streamingRef.current = typeof value === "function" ? value(streamingRef.current) : value;
setStreamingState(streamingRef.current);
};
// The turn's live thinking text (reasoning_delta events) — same ref-mirror pattern.
// Folded onto the assistant item when the message finalizes; cleared on turn_start.
const [reasoningStream, setReasoningStreamState] = useState("");
const reasoningRef = useRef("");
const setReasoningStream = (value: string) => {
reasoningRef.current = value;
setReasoningStreamState(value);
};
const [todo, setTodo] = useState<TodoItem[]>([]);
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [projects, setProjects] = useState<RecentWorkspace[]>([]);
@@ -527,9 +535,19 @@ export function App() {
// the same text server-side, so the live view and a session reload now agree.
const flushPartialStream = () => {
const partial = streamingRef.current;
if (!partial) return;
const thinking = reasoningRef.current;
if (!partial && !thinking) return;
setStreaming("");
setItems((p) => [...p, { kind: "assistant", text: partial, ts: Date.now() / 1000 }]);
setReasoningStream("");
setItems((p) => [
...p,
{
kind: "assistant",
text: partial,
ts: Date.now() / 1000,
...(thinking ? { reasoning: thinking } : {}),
},
]);
};
switch (ev.type) {
case "ready":
@@ -542,6 +560,7 @@ export function App() {
case "turn_start":
setRunning(true);
setStreaming("");
setReasoningStream("");
// Background-delivered turns (channel message, self-wake, durable resume) have no local
// send(), so the triggering message isn't in `items` yet — surface it. A connector message
// carries a structured `source` (§3.1) → render the rich card; otherwise a plain user item.
@@ -566,10 +585,27 @@ export function App() {
case "assistant_delta":
setStreaming((s) => s + (d.text || ""));
break;
case "assistant_message":
if (d.text) setItems((p) => [...p, { kind: "assistant", text: d.text, ts: Date.now() / 1000 }]);
setStreaming(""); // finalized into items (or empty tool-only turn)
case "reasoning_delta":
setReasoningStream(reasoningRef.current + (d.text || ""));
break;
case "assistant_message": {
// The event's reasoning is authoritative (covers background-delivered turns);
// the local buffer is the fallback for older servers.
const reasoning = d.reasoning || reasoningRef.current;
if (d.text || reasoning)
setItems((p) => [
...p,
{
kind: "assistant",
text: d.text || "",
ts: Date.now() / 1000,
...(reasoning ? { reasoning } : {}),
},
]);
setStreaming(""); // finalized into items (or empty tool-only turn)
setReasoningStream("");
break;
}
case "tool_proposed":
if (d.name === "todo_write" && (d.arguments?.todos || d.arguments?.items))
setTodo(normalizeTodos(d.arguments.todos ?? d.arguments.items));
@@ -1421,7 +1457,16 @@ export function App() {
// floating paragraph.
streamingText={streamMode(streaming, items, running) === "quiet" ? streaming : undefined}
/>
{/* Live thinking (reasoning models): a quiet collapsed block that streams the
trace for anyone who expands it; folds into the answer's disclosure when
the message finalizes. */}
{running && reasoningStream && !streaming && (
<div className="transcript">
<ThinkingBlock text={reasoningStream} live />
</div>
)}
{running &&
!reasoningStream &&
(!streaming || streamMode(streaming, items, running) === "hold") &&
!lastItemIsAssistant(items) && <WaitingForAgent />}
{streaming && streamMode(streaming, items, running) === "answer" && (
@@ -50,6 +50,32 @@ function BubbleMeta({ text, ts, align }: { text: string; ts?: number; align: "le
);
}
// Reasoning-model thinking text (model-layer roadmap item 4): a quiet disclosure —
// collapsed by default, the trace one click away. `live` = still streaming (pulsing label);
// App renders that variant above the transcript, this one rides a finalized assistant item.
export function ThinkingBlock({ text, live }: { text: string; live?: boolean }) {
const [open, setOpen] = useState(false);
return (
<div className="thinking">
<button
className="thinking-head"
onClick={() => setOpen((v) => !v)}
data-testid="thinking-toggle"
>
<Icon name="chevronDown" size={12} className={"thinking-caret" + (open ? " open" : "")} />
<span className={live ? "thinking-live" : undefined}>
{live ? "Thinking…" : "Thought process"}
</span>
</button>
{open && (
<div className="thinking-body" data-testid="thinking-body">
{text}
</div>
)}
</div>
);
}
type ToolItem = Extract<Item, { kind: "tool" }>;
type ApprovalItem = Extract<Item, { kind: "approval" }>;
type AssistantItem = Extract<Item, { kind: "assistant" }>;
@@ -72,6 +98,8 @@ function buildRows(items: TurnItem[]): TurnRow[] {
// same-name tool that doesn't have one yet (approvals may stream before or after their call).
const rows: TurnRow[] = items
.filter((it): it is ToolItem | AssistantItem => it.kind !== "approval")
// Thinking-only assistant items (no text) carry nothing narratable — skip the row.
.filter((it) => it.kind !== "assistant" || it.text)
.map((it) =>
it.kind === "assistant" ? { type: "narr" as const, text: it.text } : { type: "step" as const, tool: it },
);
@@ -363,9 +391,17 @@ export function Transcript({ items, running, streamingText, onRetry }: Props) {
</div>
);
case "assistant":
// Thinking-only item (stopped mid-reasoning): just the disclosure, no bubble.
if (!item.text && item.reasoning)
return (
<div key={bi}>
<ThinkingBlock text={item.reasoning} />
</div>
);
return (
<div className="group bubble-assistant" key={bi}>
<div className="who">assistant</div>
{item.reasoning && <ThinkingBlock text={item.reasoning} />}
<Markdown text={item.text} />
<BubbleMeta text={item.text} ts={item.ts} align="left" />
</div>
@@ -82,3 +82,15 @@ describe("itemsFromMessages model switch", () => {
});
});
});
describe("itemsFromMessages reasoning", () => {
it("attaches the reasoning sidecar to assistant items; thinking-only messages still render", () => {
const items = itemsFromMessages([
{ role: "user", content: "hi" },
{ role: "assistant", content: "answer", reasoning: "let me think" },
{ role: "assistant", content: "", reasoning: "stopped mid-thought" },
] as any);
expect(items[1]).toEqual({ kind: "assistant", text: "answer", reasoning: "let me think" });
expect(items[2]).toEqual({ kind: "assistant", text: "", reasoning: "stopped mid-thought" });
});
});
+7 -2
View File
@@ -37,8 +37,13 @@ export function itemsFromMessages(messages: ConversationMessage[]): Item[] {
if (typeof m.ts === "number") user.ts = m.ts;
if (user.text || user.attachments?.length) items.push(user);
} else if (m.role === "assistant") {
if (m.content)
items.push({ kind: "assistant", text: m.content, ...(typeof m.ts === "number" ? { ts: m.ts } : {}) });
if (m.content || m.reasoning)
items.push({
kind: "assistant",
text: m.content || "",
...(typeof m.ts === "number" ? { ts: m.ts } : {}),
...(m.reasoning ? { reasoning: m.reasoning } : {}),
});
for (const tc of m.tool_calls || []) {
let args: any = {};
try {
+18
View File
@@ -1561,3 +1561,21 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb {
}
html[data-platform="windows"] ::-webkit-scrollbar-thumb:hover,
html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color: var(--faint); }
/* -- thinking (reasoning trace) disclosure — model-layer roadmap item 4 ----------- */
.thinking { margin: 2px 0 4px; }
.thinking-head {
display: flex; align-items: center; gap: 5px;
font-size: 12px; color: var(--faint);
background: none; border: none; padding: 2px 0; cursor: pointer;
}
.thinking-head:hover { color: var(--muted); }
.thinking-caret { transition: transform 0.15s; transform: rotate(-90deg); }
.thinking-caret.open { transform: rotate(0); }
.thinking-live { animation: boot-pulse 1.4s ease-in-out infinite; }
.thinking-body {
font-size: 12px; line-height: 1.55; color: var(--muted);
white-space: pre-wrap; overflow-wrap: anywhere;
border-left: 2px solid var(--line); padding: 4px 0 4px 10px; margin: 4px 0 6px 4px;
max-height: 280px; overflow-y: auto;
}
+2 -1
View File
@@ -3,6 +3,7 @@ export type EventType =
| "inbound"
| "turn_start"
| "assistant_delta"
| "reasoning_delta"
| "assistant_message"
| "tool_proposed"
| "permission_required"
@@ -78,7 +79,7 @@ export type Item =
// (ConnectorMessageCard) instead of a plain user bubble. Generalizes to any connector via the
// registry — no per-connector special-casing.
| { kind: "connector"; source: MessageSource }
| { kind: "assistant"; text: string; ts?: number }
| { kind: "assistant"; text: string; ts?: number; reasoning?: string }
// `hidden` = results the user's privacy filters removed before the agent saw them
// (from the tool message's `_display` sidecar; the agent-visible content has no trace).
// `standingRule` = the task-scoped rule that auto-allowed this call ("tool → target").