Merge pull request #7940 from SivanCola/fix/thinking-trace-markdown

Render thinking traces as Markdown without stream lag / 流畅渲染思考过程 Markdown
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-08 15:50:10 +08:00
commit 7ae8c54a5a
12 files changed
+164 -37

No files matched your search

@@ -257,6 +257,12 @@ console.log("\nmermaid rendering");
});
eq(pendingFrame, undefined, "tail growth alone schedules no further parse");
await act(async () => {
root.render(<MarkdownTextProbe text={"...\nreplacement window"} streaming />);
await flushTimers();
});
eq(rootEl.textContent, "", "a rolling Markdown window drops its stale parsed prefix before paint");
await act(async () => {
root.render(<MarkdownTextProbe text="complete" streaming={false} />);
});
@@ -1,11 +1,21 @@
// Run: tsx src/__tests__/message-reasoning-panel.test.tsx
import { JSDOM } from "jsdom";
import { registerHooks } from "node:module";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { LocaleProvider } from "../lib/i18n";
import { AssistantMessage } from "../components/Message";
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.endsWith(".css")) {
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
}
return nextResolve(specifier, context);
},
});
let passed = 0;
let failed = 0;
@@ -30,6 +40,7 @@ globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: { ...dom.window.navigator, language: "en-US" } });
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.MouseEvent = dom.window.MouseEvent;
@@ -49,7 +60,7 @@ await act(async () => {
kind: "assistant",
id: "a1",
text: "",
reasoning: "line one\nline two",
reasoning: "**important trace**\n\n- line one\n- line two\n\n`inline code`",
streaming: false,
reasoningComplete: true,
reasoningDurationMs: 2_600,
@@ -67,9 +78,13 @@ ok(!document.querySelector(".reasoning__body"), "completed reasoning is collapse
await act(async () => {
header?.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
});
ok(document.querySelector(".reasoning__body")?.textContent?.includes("line two") ?? false, "clicking the header expands the reasoning body");
ok(document.querySelector(".reasoning__body strong")?.textContent === "important trace", "reasoning renders Markdown emphasis");
ok(document.querySelectorAll(".reasoning__body li").length === 2, "reasoning renders Markdown lists");
ok(document.querySelector(".reasoning__body .md-code")?.textContent === "inline code", "reasoning renders Markdown inline code");
await act(async () => {
root.unmount();
@@ -41,5 +41,17 @@ eq(
"can opt out of streaming truncation",
);
eq(
displayReasoningText("abcdefgh", { streaming: true, maxChars: 4, maxLines: 10, stableWindowChars: 3 }),
"...\ndefgh",
"keeps a stable append-only character window between coarse rebases",
);
eq(
displayReasoningText("a\nb\nc\nd\ne", { streaming: true, maxChars: 100, maxLines: 2, stableWindowLines: 2 }),
"...\nc\nd\ne",
"keeps a stable append-only line window between coarse rebases",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -5,6 +5,7 @@
// response preview / notices), including the terminal phase visuals.
import { JSDOM } from "jsdom";
import { registerHooks } from "node:module";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
@@ -13,6 +14,15 @@ import { ToolCard } from "../components/ToolCard";
import { LocaleProvider } from "../lib/i18n";
import type { Item, SubagentProgress } from "../lib/useController";
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.endsWith(".css")) {
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
}
return nextResolve(specifier, context);
},
});
type ToolItem = Extract<Item, { kind: "tool" }>;
// jsdom has no layout engine: stub the GSAP tween surface the collapse hook
@@ -94,7 +104,7 @@ function makeItem(phase: SubagentProgress["phase"], over: Partial<SubagentProgre
status: phase === "completed" || phase === "failed" ? "done" : phase === "cancelled" ? "stopped" : "running",
subagentProgress: {
phase,
reasoning: "thinking step by step",
reasoning: "**thinking** step by step\n\n- inspect\n- verify",
text: "draft answer preview",
notice: "heads up",
lastActivityAt: now - 3_000,
@@ -131,6 +141,7 @@ console.log("\nsubagent progress card");
// Expanded body shows reasoning / response / notices without ordinary output.
const head = document.querySelector(".tool__head") as HTMLButtonElement | null;
ok(!!head, "card head renders");
ok(!document.querySelector(".tool__subagent-preview-text .md"), "collapsed reasoning preview skips Markdown rendering");
await act(async () => {
head?.click();
await flushTimers();
@@ -138,6 +149,8 @@ console.log("\nsubagent progress card");
ok(!!document.querySelector(".tool__subagent-preview"), "expanded body renders the preview block");
ok(document.querySelector(".tool__subagent-preview-label")?.textContent === "Reasoning", "reasoning section label");
ok(document.body.textContent?.includes("thinking step by step"), "reasoning preview text visible");
ok(document.querySelector(".tool__subagent-preview-text strong")?.textContent === "thinking", "reasoning preview renders Markdown emphasis");
ok(document.querySelectorAll(".tool__subagent-preview-text li").length === 2, "reasoning preview renders Markdown lists");
ok(document.body.textContent?.includes("draft answer preview"), "response preview text visible");
ok(document.body.textContent?.includes("heads up"), "notice preview text visible");
@@ -32,7 +32,8 @@ const FRAME_MS = 1000 / 60;
interface SimResult {
frames: number;
commits: number;
markdownParses: number;
answerMarkdownParses: number;
reasoningMarkdownParses: number;
itemsIdentityBreaks: number;
bumpSkipViolations: number;
state: typeof initialState;
@@ -49,15 +50,20 @@ function simulate(spec: UIPerfScenario, base?: typeof initialState): SimResult {
let frames = 0;
let commits = 0;
let markdownParses = 0;
let answerMarkdownParses = 0;
let reasoningMarkdownParses = 0;
let itemsIdentityBreaks = 0;
let bumpSkipViolations = 0;
let text = "";
let renderedLen = 0;
let lastParseAt = -Infinity;
let reasoning = "";
let answerRenderedLen = 0;
let reasoningRenderedLen = 0;
let lastAnswerParseAt = -Infinity;
let lastReasoningParseAt = -Infinity;
let pendingChunks = 0;
let index = 0;
let firstBatch = true;
let reasoningFinalized = false;
while (index < chunks.length) {
frames += 1;
@@ -68,6 +74,7 @@ function simulate(spec: UIPerfScenario, base?: typeof initialState): SimResult {
index += 1;
pendingChunks -= 1;
if (chunk.kind === "text") text += chunk.delta;
else reasoning += chunk.delta;
batch.push({ tabId: "a", e: { kind: chunk.kind, text: chunk.delta } as WireEvent });
}
if (batch.length === 0) continue;
@@ -85,17 +92,31 @@ function simulate(spec: UIPerfScenario, base?: typeof initialState): SimResult {
firstBatch = false;
}
const nowMs = frames * FRAME_MS;
if (nowMs - lastParseAt >= streamingMarkdownCommitInterval(text.length)) {
if (spec.reasoningVisible && reasoning.length > 0 && text.length > 0 && !reasoningFinalized) {
reasoningMarkdownParses += 1;
reasoningRenderedLen = reasoning.length;
reasoningFinalized = true;
}
if (spec.reasoningVisible && !reasoningFinalized && nowMs - lastReasoningParseAt >= streamingMarkdownCommitInterval(reasoning.length)) {
const target = streamingCommitTarget(reasoning);
if (target.length > reasoningRenderedLen) {
reasoningMarkdownParses += 1;
reasoningRenderedLen = target.length;
lastReasoningParseAt = nowMs;
}
}
if (nowMs - lastAnswerParseAt >= streamingMarkdownCommitInterval(text.length)) {
const target = streamingCommitTarget(text);
if (target.length > renderedLen) {
markdownParses += 1;
renderedLen = target.length;
lastParseAt = nowMs;
if (target.length > answerRenderedLen) {
answerMarkdownParses += 1;
answerRenderedLen = target.length;
lastAnswerParseAt = nowMs;
}
}
}
markdownParses += 1; // end-of-stream finalization parse
return { frames, commits, markdownParses, itemsIdentityBreaks, bumpSkipViolations, state };
if (text.length > answerRenderedLen) answerMarkdownParses += 1;
if (spec.reasoningVisible && reasoning.length > reasoningRenderedLen) reasoningMarkdownParses += 1;
return { frames, commits, answerMarkdownParses, reasoningMarkdownParses, itemsIdentityBreaks, bumpSkipViolations, state };
}
const byId = new Map(UI_PERF_SCENARIOS.map((s) => [s.id, s]));
@@ -111,8 +132,8 @@ const scenario = (id: string): UIPerfScenario => {
const r = simulate(spec);
ok(r.commits <= r.frames + 1, `01: one reducer pass per frame at most (${r.commits} commits / ${r.frames} frames)`);
ok(
r.markdownParses <= spec.paragraphs + 3,
`01: markdown parses bounded by blocks, not ticks (${r.markdownParses} for ${spec.paragraphs} paragraphs)`,
r.answerMarkdownParses <= spec.paragraphs + 3,
`01: markdown parses bounded by blocks, not ticks (${r.answerMarkdownParses} for ${spec.paragraphs} paragraphs)`,
);
ok(r.state.live !== undefined && r.state.live.text.length >= spec.textChars, "01: full answer reached the live stream");
}
@@ -123,7 +144,8 @@ const scenario = (id: string): UIPerfScenario => {
const r = simulate(spec);
const seconds = r.frames / 60;
ok(r.commits / seconds <= 61, `02: state commits stay at or under display FPS (${(r.commits / seconds).toFixed(1)}/s)`);
ok(r.markdownParses <= spec.paragraphs + 3, `02: reasoning stream causes no markdown parses (${r.markdownParses})`);
ok(r.reasoningMarkdownParses <= 2, `02: visible reasoning Markdown stays within its parse budget (${r.reasoningMarkdownParses})`);
ok(r.answerMarkdownParses <= spec.paragraphs + 3, `02: answer Markdown keeps its existing parse budget (${r.answerMarkdownParses})`);
ok(r.state.live !== undefined && r.state.live.reasoning.length >= spec.reasoningChars, "02: full reasoning reached the live stream");
eq(r.state.live?.reasoningComplete, true, "02: answer text after reasoning completed it");
}
@@ -134,12 +156,12 @@ const scenario = (id: string): UIPerfScenario => {
const r = simulate(spec);
ok(r.commits <= r.frames + 1, `03: commits bounded by frames (${r.commits}/${r.frames})`);
ok(
r.markdownParses >= spec.codeFences,
`03: open fences keep committing so streamed code stays highlighted (${r.markdownParses} parses)`,
r.answerMarkdownParses >= spec.codeFences,
`03: open fences keep committing so streamed code stays highlighted (${r.answerMarkdownParses} parses)`,
);
ok(
r.markdownParses <= Math.ceil(r.frames / 3) + spec.paragraphs + spec.codeFences,
`03: parse cadence capped by the 50ms tier even inside fences (${r.markdownParses} parses / ${r.frames} frames)`,
r.answerMarkdownParses <= Math.ceil(r.frames / 3) + spec.paragraphs + spec.codeFences,
`03: parse cadence capped by the 50ms tier even inside fences (${r.answerMarkdownParses} parses / ${r.frames} frames)`,
);
}
@@ -168,6 +190,7 @@ const scenario = (id: string): UIPerfScenario => {
{
const r = simulate(scenario("UI-PERF-06"));
eq(r.bumpSkipViolations, 0, "06: background streaming never trips the whole-App bump — liveStore only");
eq(r.reasoningMarkdownParses, 0, "06: background reasoning performs no Markdown parsing");
}
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
@@ -191,6 +191,13 @@ export function useRenderedMarkdownText(text: string, streaming: boolean): strin
cancelFinalizationRef.current?.();
cancelFinalizationRef.current = null;
finalizingTextRef.current = null;
// A bounded live preview occasionally advances its window and drops an
// old prefix. Discard the stale parsed tree before paint; the complete
// replacement stays visible through StreamingMarkdownTail and is parsed
// later under the normal adaptive budget.
if (renderedText !== "" && !text.startsWith(renderedText)) {
setRenderedText("");
}
return;
}
lastCommitAtRef.current = 0;
+4 -4
View File
@@ -13,7 +13,7 @@ import { useT } from "../lib/i18n";
import { ImageViewer } from "./ImageViewer";
import { Tooltip } from "./Tooltip";
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
import { displayReasoningText } from "../lib/reasoningDisplay";
import { displayReasoningText, STREAMING_REASONING_WINDOW_STEP_CHARS, STREAMING_REASONING_WINDOW_STEP_LINES } from "../lib/reasoningDisplay";
import { stripMemoryCompilerExecution } from "../lib/memoryCompilerDisplay";
import { visibleTranscriptMemoryCitations } from "../lib/memoryCitationVisibility";
import { invocationSegmentsFromMessage, type InvocationMetadataMap } from "../lib/invocationDisplay";
@@ -914,8 +914,8 @@ function ReasoningPanel({
const isReasoningRunning = item.streaming && !item.reasoningComplete;
const visibleReasoning = reasoningOpen
? displayReasoningText(item.reasoning, {
streaming: item.streaming,
truncateStreaming: truncateStreamingReasoning,
streaming: isReasoningRunning,
truncateStreaming: truncateStreamingReasoning, stableWindowChars: STREAMING_REASONING_WINDOW_STEP_CHARS, stableWindowLines: STREAMING_REASONING_WINDOW_STEP_LINES,
})
: "";
const label = isReasoningRunning ? t("msg.thinkingRunning") : t("msg.thinking");
@@ -936,7 +936,7 @@ function ReasoningPanel({
<ChevronRight className={`reasoning__chevron${reasoningOpen ? " reasoning__chevron--open" : ""}`} size={12} />
</button>
{reasoningOpen && (
<div ref={reasoningBodyRef} className="reasoning__body">{visibleReasoning}</div>
<div ref={reasoningBodyRef} className="reasoning__body"><Markdown text={visibleReasoning} streaming={isReasoningRunning} /></div>
)}
</div>
);
+8 -1
View File
@@ -9,6 +9,7 @@ import { useGSAPCollapse } from "../lib/useGSAPCollapse";
import { isTerminalSubagentPhase, type Item, type SubagentPhase } from "../lib/useController";
import type { Translator } from "../lib/i18n";
import { ReadOnlyBatch } from "./ReadOnlyBatch";
import { Markdown } from "./Markdown";
type ToolItem = Extract<Item, { kind: "tool" }>;
@@ -357,7 +358,13 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
{sp.reasoning && (
<div className="tool__subagent-preview-section">
<div className="tool__subagent-preview-label">{t("subagent.preview.reasoning")}</div>
<pre className="tool__subagent-preview-text">{sp.reasoning}</pre>
{open ? (
<div className="tool__subagent-preview-text tool__subagent-preview-text--markdown">
<Markdown text={sp.reasoning} streaming={sp.phase === "reasoning"} />
</div>
) : (
<pre className="tool__subagent-preview-text">{sp.reasoning}</pre>
)}
</div>
)}
{sp.text && (
@@ -19,8 +19,9 @@ import { useEntranceAnimation } from "../lib/useEntranceAnimation";
import { useScrollManager } from "../lib/useScrollManager";
import { buildTurnGroups, compactQuestionText, createWarmLayerState, lastQuestionTurn, questionAnchorId, questionTurnsById, scrollVersion, warmColdPageForTurn, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination, warmUserPreview, type QuestionAnchor, type TurnGroup, type WarmLayerState } from "../lib/transcriptGrouping";
import { appendTurnActionCopyText } from "../lib/turnActionCopy";
import { displayReasoningText } from "../lib/reasoningDisplay";
import { displayReasoningText, STREAMING_REASONING_WINDOW_STEP_CHARS, STREAMING_REASONING_WINDOW_STEP_LINES } from "../lib/reasoningDisplay";
import { observeScrollContentSize } from "../lib/scrollContentObserver";
import { Markdown } from "./Markdown";
type ToolItem = Extract<Item, { kind: "tool" }>;
type AssistantItem = Extract<Item, { kind: "assistant" }>;
@@ -81,7 +82,7 @@ const LiveAssistantMessage = memo(function LiveAssistantMessage({
);
});
function InlineAssistantReasoning({ item }: { item: AssistantItem }) {
function InlineAssistantReasoning({ item, active }: { item: AssistantItem; active: boolean }) {
const t = useT();
const live = useContext(LiveStreamContext);
const [open, setOpen] = useState(true);
@@ -94,13 +95,12 @@ function InlineAssistantReasoning({ item }: { item: AssistantItem }) {
reasoningComplete: live.reasoningComplete,
}
: item;
const reasoning = shown.reasoning.trim();
const reasoning = shown.reasoning.trim(); const running = shown.streaming && !shown.reasoningComplete;
if (!reasoning) return null;
const visibleReasoning = displayReasoningText(shown.reasoning, {
streaming: shown.streaming,
truncateStreaming: true,
streaming: running,
truncateStreaming: true, stableWindowChars: STREAMING_REASONING_WINDOW_STEP_CHARS, stableWindowLines: STREAMING_REASONING_WINDOW_STEP_LINES,
});
const running = shown.streaming && !shown.reasoningComplete;
return (
<div className={`turn-collapse__reasoning-phase${open ? " turn-collapse__reasoning-phase--open" : ""}`}>
<button
@@ -114,7 +114,7 @@ function InlineAssistantReasoning({ item }: { item: AssistantItem }) {
<span>{running ? t("msg.thinkingRunning") : t("msg.thinking")}</span>
<ChevronRight className={`reasoning__chevron${open ? " reasoning__chevron--open" : ""}`} size={12} />
</button>
<div ref={bodyRef} className="turn-collapse__inline-reasoning">{visibleReasoning}</div>
<div ref={bodyRef} className="turn-collapse__inline-reasoning">{active && open ? <Markdown text={visibleReasoning} streaming={running} /> : visibleReasoning}</div>
</div>
);
}
@@ -1525,7 +1525,7 @@ function TurnCollapse({ items, durationMs, mode, subcalls, tabId, creationMode =
case "assistant":
// Answer text renders outside the fold (partitionTurnItems strips it),
// so the fold only ever shows the reasoning segment.
body.push(<InlineAssistantReasoning key={`${it.id}-reasoning`} item={it as AssistantItem} />);
body.push(<InlineAssistantReasoning key={`${it.id}-reasoning`} item={it as AssistantItem} active={open} />);
break;
}
}
+22 -4
View File
@@ -1,11 +1,15 @@
export const STREAMING_REASONING_TAIL_CHARS = 12_000;
export const STREAMING_REASONING_TAIL_LINES = 240;
export const STREAMING_REASONING_WINDOW_STEP_CHARS = 2_000;
export const STREAMING_REASONING_WINDOW_STEP_LINES = 40;
type ReasoningDisplayOptions = {
streaming: boolean;
truncateStreaming?: boolean;
maxChars?: number;
maxLines?: number;
stableWindowChars?: number;
stableWindowLines?: number;
};
export function displayReasoningText(
@@ -15,6 +19,8 @@ export function displayReasoningText(
truncateStreaming = true,
maxChars = STREAMING_REASONING_TAIL_CHARS,
maxLines = STREAMING_REASONING_TAIL_LINES,
stableWindowChars = 0,
stableWindowLines = 0,
}: ReasoningDisplayOptions,
): string {
if (!streaming || !truncateStreaming) return reasoning;
@@ -23,15 +29,27 @@ export function displayReasoningText(
let truncated = false;
if (maxChars > 0 && text.length > maxChars) {
text = text.slice(-maxChars);
truncated = true;
const overflow = text.length - maxChars;
const start = stableWindowChars > 0
? Math.floor(overflow / stableWindowChars) * stableWindowChars
: overflow;
if (start > 0) {
text = text.slice(start);
truncated = true;
}
}
if (maxLines > 0) {
const lines = text.split(/\r?\n/);
if (lines.length > maxLines) {
text = lines.slice(-maxLines).join("\n");
truncated = true;
const overflow = lines.length - maxLines;
const start = stableWindowLines > 0
? Math.floor(overflow / stableWindowLines) * stableWindowLines
: overflow;
if (start > 0) {
text = lines.slice(start).join("\n");
truncated = true;
}
}
}
@@ -11,6 +11,7 @@ export interface UIPerfScenario {
title: string;
chunksPerSec: number;
reasoningChars: number;
reasoningVisible: boolean;
textChars: number;
paragraphs: number;
codeFences: number;
@@ -26,6 +27,7 @@ export const UI_PERF_SCENARIOS: UIPerfScenario[] = [
title: "normal answer: 2KB markdown prose",
chunksPerSec: 80,
reasoningChars: 0,
reasoningVisible: false,
textChars: 2_000,
paragraphs: 8,
codeFences: 0,
@@ -36,6 +38,7 @@ export const UI_PERF_SCENARIOS: UIPerfScenario[] = [
title: "DeepSeek reasoning: 16KB reasoning + 8KB answer",
chunksPerSec: 150,
reasoningChars: 16_000,
reasoningVisible: true,
textChars: 8_000,
paragraphs: 16,
codeFences: 0,
@@ -46,6 +49,7 @@ export const UI_PERF_SCENARIOS: UIPerfScenario[] = [
title: "code heavy: 10 fences in 20KB markdown",
chunksPerSec: 120,
reasoningChars: 0,
reasoningVisible: false,
textChars: 20_000,
paragraphs: 10,
codeFences: 10,
@@ -56,6 +60,7 @@ export const UI_PERF_SCENARIOS: UIPerfScenario[] = [
title: "interaction under streaming: typing, wheel, selection, tool cards",
chunksPerSec: 150,
reasoningChars: 8_000,
reasoningVisible: true,
textChars: 8_000,
paragraphs: 12,
codeFences: 2,
@@ -67,6 +72,7 @@ export const UI_PERF_SCENARIOS: UIPerfScenario[] = [
title: "long session: 100 turns, 30 tool calls, diffs and code",
chunksPerSec: 100,
reasoningChars: 0,
reasoningVisible: false,
textChars: 4_000,
paragraphs: 10,
codeFences: 2,
@@ -78,6 +84,7 @@ export const UI_PERF_SCENARIOS: UIPerfScenario[] = [
title: "background agent: tab A streams while tab B is active",
chunksPerSec: 150,
reasoningChars: 4_000,
reasoningVisible: false,
textChars: 4_000,
paragraphs: 8,
codeFences: 0,
+19
View File
@@ -4963,6 +4963,25 @@ a[href] {
line-height: 1.72;
white-space: pre-wrap;
}
.reasoning__body > .md,
.turn-collapse__inline-reasoning > .md,
.tool__subagent-preview-text--markdown > .md {
color: inherit;
font-size: inherit;
line-height: inherit;
white-space: normal;
}
.reasoning__body > .md p,
.turn-collapse__inline-reasoning > .md p,
.tool__subagent-preview-text--markdown > .md p {
margin-bottom: 8px;
}
.reasoning__body > .md :where(h1, h2, h3, h4),
.turn-collapse__inline-reasoning > .md :where(h1, h2, h3, h4),
.tool__subagent-preview-text--markdown > .md :where(h1, h2, h3, h4) {
margin: 12px 0 6px;
font-size: 1em;
}
/* ── markdown prose ──────────────────────────────────────────────────────── */
.md {
font-family: var(--font-content-family);