Skip to content

Commit 7ae8c54

Browse files
authored
Merge pull request #7940 from SivanCola/fix/thinking-trace-markdown
Render thinking traces as Markdown without stream lag / 流畅渲染思考过程 Markdown
2 parents 34dccef + 186b06a commit 7ae8c54

12 files changed

Lines changed: 164 additions & 37 deletions

desktop/frontend/src/__tests__/mermaid-rendering.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,12 @@ console.log("\nmermaid rendering");
257257
});
258258
eq(pendingFrame, undefined, "tail growth alone schedules no further parse");
259259

260+
await act(async () => {
261+
root.render(<MarkdownTextProbe text={"...\nreplacement window"} streaming />);
262+
await flushTimers();
263+
});
264+
eq(rootEl.textContent, "", "a rolling Markdown window drops its stale parsed prefix before paint");
265+
260266
await act(async () => {
261267
root.render(<MarkdownTextProbe text="complete" streaming={false} />);
262268
});

desktop/frontend/src/__tests__/message-reasoning-panel.test.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
// Run: tsx src/__tests__/message-reasoning-panel.test.tsx
22

33
import { JSDOM } from "jsdom";
4+
import { registerHooks } from "node:module";
45
import React, { act } from "react";
56
import { createRoot } from "react-dom/client";
67
import { LocaleProvider } from "../lib/i18n";
78
import { AssistantMessage } from "../components/Message";
89

10+
registerHooks({
11+
resolve(specifier, context, nextResolve) {
12+
if (specifier.endsWith(".css")) {
13+
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
14+
}
15+
return nextResolve(specifier, context);
16+
},
17+
});
18+
919
let passed = 0;
1020
let failed = 0;
1121

@@ -30,6 +40,7 @@ globalThis.window = dom.window as unknown as Window & typeof globalThis;
3040
globalThis.document = dom.window.document;
3141
Object.defineProperty(globalThis, "navigator", { configurable: true, value: { ...dom.window.navigator, language: "en-US" } });
3242
globalThis.Node = dom.window.Node;
43+
globalThis.Element = dom.window.Element;
3344
globalThis.HTMLElement = dom.window.HTMLElement;
3445
globalThis.Event = dom.window.Event;
3546
globalThis.MouseEvent = dom.window.MouseEvent;
@@ -49,7 +60,7 @@ await act(async () => {
4960
kind: "assistant",
5061
id: "a1",
5162
text: "",
52-
reasoning: "line one\nline two",
63+
reasoning: "**important trace**\n\n- line one\n- line two\n\n`inline code`",
5364
streaming: false,
5465
reasoningComplete: true,
5566
reasoningDurationMs: 2_600,
@@ -67,9 +78,13 @@ ok(!document.querySelector(".reasoning__body"), "completed reasoning is collapse
6778

6879
await act(async () => {
6980
header?.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
81+
await new Promise((resolve) => setTimeout(resolve, 0));
7082
});
7183

7284
ok(document.querySelector(".reasoning__body")?.textContent?.includes("line two") ?? false, "clicking the header expands the reasoning body");
85+
ok(document.querySelector(".reasoning__body strong")?.textContent === "important trace", "reasoning renders Markdown emphasis");
86+
ok(document.querySelectorAll(".reasoning__body li").length === 2, "reasoning renders Markdown lists");
87+
ok(document.querySelector(".reasoning__body .md-code")?.textContent === "inline code", "reasoning renders Markdown inline code");
7388

7489
await act(async () => {
7590
root.unmount();

desktop/frontend/src/__tests__/reasoning-display.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,17 @@ eq(
4141
"can opt out of streaming truncation",
4242
);
4343

44+
eq(
45+
displayReasoningText("abcdefgh", { streaming: true, maxChars: 4, maxLines: 10, stableWindowChars: 3 }),
46+
"...\ndefgh",
47+
"keeps a stable append-only character window between coarse rebases",
48+
);
49+
50+
eq(
51+
displayReasoningText("a\nb\nc\nd\ne", { streaming: true, maxChars: 100, maxLines: 2, stableWindowLines: 2 }),
52+
"...\nc\nd\ne",
53+
"keeps a stable append-only line window between coarse rebases",
54+
);
55+
4456
console.log(`\n${passed} passed, ${failed} failed`);
4557
if (failed > 0) process.exit(1);

desktop/frontend/src/__tests__/subagent-progress-card.test.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// response preview / notices), including the terminal phase visuals.
66

77
import { JSDOM } from "jsdom";
8+
import { registerHooks } from "node:module";
89
import React from "react";
910
import { act } from "react";
1011
import { createRoot } from "react-dom/client";
@@ -13,6 +14,15 @@ import { ToolCard } from "../components/ToolCard";
1314
import { LocaleProvider } from "../lib/i18n";
1415
import type { Item, SubagentProgress } from "../lib/useController";
1516

17+
registerHooks({
18+
resolve(specifier, context, nextResolve) {
19+
if (specifier.endsWith(".css")) {
20+
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
21+
}
22+
return nextResolve(specifier, context);
23+
},
24+
});
25+
1626
type ToolItem = Extract<Item, { kind: "tool" }>;
1727

1828
// 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
94104
status: phase === "completed" || phase === "failed" ? "done" : phase === "cancelled" ? "stopped" : "running",
95105
subagentProgress: {
96106
phase,
97-
reasoning: "thinking step by step",
107+
reasoning: "**thinking** step by step\n\n- inspect\n- verify",
98108
text: "draft answer preview",
99109
notice: "heads up",
100110
lastActivityAt: now - 3_000,
@@ -131,13 +141,16 @@ console.log("\nsubagent progress card");
131141
// Expanded body shows reasoning / response / notices without ordinary output.
132142
const head = document.querySelector(".tool__head") as HTMLButtonElement | null;
133143
ok(!!head, "card head renders");
144+
ok(!document.querySelector(".tool__subagent-preview-text .md"), "collapsed reasoning preview skips Markdown rendering");
134145
await act(async () => {
135146
head?.click();
136147
await flushTimers();
137148
});
138149
ok(!!document.querySelector(".tool__subagent-preview"), "expanded body renders the preview block");
139150
ok(document.querySelector(".tool__subagent-preview-label")?.textContent === "Reasoning", "reasoning section label");
140151
ok(document.body.textContent?.includes("thinking step by step"), "reasoning preview text visible");
152+
ok(document.querySelector(".tool__subagent-preview-text strong")?.textContent === "thinking", "reasoning preview renders Markdown emphasis");
153+
ok(document.querySelectorAll(".tool__subagent-preview-text li").length === 2, "reasoning preview renders Markdown lists");
141154
ok(document.body.textContent?.includes("draft answer preview"), "response preview text visible");
142155
ok(document.body.textContent?.includes("heads up"), "notice preview text visible");
143156

desktop/frontend/src/__tests__/ui-perf-scenarios.test.ts

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ const FRAME_MS = 1000 / 60;
3232
interface SimResult {
3333
frames: number;
3434
commits: number;
35-
markdownParses: number;
35+
answerMarkdownParses: number;
36+
reasoningMarkdownParses: number;
3637
itemsIdentityBreaks: number;
3738
bumpSkipViolations: number;
3839
state: typeof initialState;
@@ -49,15 +50,20 @@ function simulate(spec: UIPerfScenario, base?: typeof initialState): SimResult {
4950

5051
let frames = 0;
5152
let commits = 0;
52-
let markdownParses = 0;
53+
let answerMarkdownParses = 0;
54+
let reasoningMarkdownParses = 0;
5355
let itemsIdentityBreaks = 0;
5456
let bumpSkipViolations = 0;
5557
let text = "";
56-
let renderedLen = 0;
57-
let lastParseAt = -Infinity;
58+
let reasoning = "";
59+
let answerRenderedLen = 0;
60+
let reasoningRenderedLen = 0;
61+
let lastAnswerParseAt = -Infinity;
62+
let lastReasoningParseAt = -Infinity;
5863
let pendingChunks = 0;
5964
let index = 0;
6065
let firstBatch = true;
66+
let reasoningFinalized = false;
6167

6268
while (index < chunks.length) {
6369
frames += 1;
@@ -68,6 +74,7 @@ function simulate(spec: UIPerfScenario, base?: typeof initialState): SimResult {
6874
index += 1;
6975
pendingChunks -= 1;
7076
if (chunk.kind === "text") text += chunk.delta;
77+
else reasoning += chunk.delta;
7178
batch.push({ tabId: "a", e: { kind: chunk.kind, text: chunk.delta } as WireEvent });
7279
}
7380
if (batch.length === 0) continue;
@@ -85,17 +92,31 @@ function simulate(spec: UIPerfScenario, base?: typeof initialState): SimResult {
8592
firstBatch = false;
8693
}
8794
const nowMs = frames * FRAME_MS;
88-
if (nowMs - lastParseAt >= streamingMarkdownCommitInterval(text.length)) {
95+
if (spec.reasoningVisible && reasoning.length > 0 && text.length > 0 && !reasoningFinalized) {
96+
reasoningMarkdownParses += 1;
97+
reasoningRenderedLen = reasoning.length;
98+
reasoningFinalized = true;
99+
}
100+
if (spec.reasoningVisible && !reasoningFinalized && nowMs - lastReasoningParseAt >= streamingMarkdownCommitInterval(reasoning.length)) {
101+
const target = streamingCommitTarget(reasoning);
102+
if (target.length > reasoningRenderedLen) {
103+
reasoningMarkdownParses += 1;
104+
reasoningRenderedLen = target.length;
105+
lastReasoningParseAt = nowMs;
106+
}
107+
}
108+
if (nowMs - lastAnswerParseAt >= streamingMarkdownCommitInterval(text.length)) {
89109
const target = streamingCommitTarget(text);
90-
if (target.length > renderedLen) {
91-
markdownParses += 1;
92-
renderedLen = target.length;
93-
lastParseAt = nowMs;
110+
if (target.length > answerRenderedLen) {
111+
answerMarkdownParses += 1;
112+
answerRenderedLen = target.length;
113+
lastAnswerParseAt = nowMs;
94114
}
95115
}
96116
}
97-
markdownParses += 1; // end-of-stream finalization parse
98-
return { frames, commits, markdownParses, itemsIdentityBreaks, bumpSkipViolations, state };
117+
if (text.length > answerRenderedLen) answerMarkdownParses += 1;
118+
if (spec.reasoningVisible && reasoning.length > reasoningRenderedLen) reasoningMarkdownParses += 1;
119+
return { frames, commits, answerMarkdownParses, reasoningMarkdownParses, itemsIdentityBreaks, bumpSkipViolations, state };
99120
}
100121

101122
const byId = new Map(UI_PERF_SCENARIOS.map((s) => [s.id, s]));
@@ -111,8 +132,8 @@ const scenario = (id: string): UIPerfScenario => {
111132
const r = simulate(spec);
112133
ok(r.commits <= r.frames + 1, `01: one reducer pass per frame at most (${r.commits} commits / ${r.frames} frames)`);
113134
ok(
114-
r.markdownParses <= spec.paragraphs + 3,
115-
`01: markdown parses bounded by blocks, not ticks (${r.markdownParses} for ${spec.paragraphs} paragraphs)`,
135+
r.answerMarkdownParses <= spec.paragraphs + 3,
136+
`01: markdown parses bounded by blocks, not ticks (${r.answerMarkdownParses} for ${spec.paragraphs} paragraphs)`,
116137
);
117138
ok(r.state.live !== undefined && r.state.live.text.length >= spec.textChars, "01: full answer reached the live stream");
118139
}
@@ -123,7 +144,8 @@ const scenario = (id: string): UIPerfScenario => {
123144
const r = simulate(spec);
124145
const seconds = r.frames / 60;
125146
ok(r.commits / seconds <= 61, `02: state commits stay at or under display FPS (${(r.commits / seconds).toFixed(1)}/s)`);
126-
ok(r.markdownParses <= spec.paragraphs + 3, `02: reasoning stream causes no markdown parses (${r.markdownParses})`);
147+
ok(r.reasoningMarkdownParses <= 2, `02: visible reasoning Markdown stays within its parse budget (${r.reasoningMarkdownParses})`);
148+
ok(r.answerMarkdownParses <= spec.paragraphs + 3, `02: answer Markdown keeps its existing parse budget (${r.answerMarkdownParses})`);
127149
ok(r.state.live !== undefined && r.state.live.reasoning.length >= spec.reasoningChars, "02: full reasoning reached the live stream");
128150
eq(r.state.live?.reasoningComplete, true, "02: answer text after reasoning completed it");
129151
}
@@ -134,12 +156,12 @@ const scenario = (id: string): UIPerfScenario => {
134156
const r = simulate(spec);
135157
ok(r.commits <= r.frames + 1, `03: commits bounded by frames (${r.commits}/${r.frames})`);
136158
ok(
137-
r.markdownParses >= spec.codeFences,
138-
`03: open fences keep committing so streamed code stays highlighted (${r.markdownParses} parses)`,
159+
r.answerMarkdownParses >= spec.codeFences,
160+
`03: open fences keep committing so streamed code stays highlighted (${r.answerMarkdownParses} parses)`,
139161
);
140162
ok(
141-
r.markdownParses <= Math.ceil(r.frames / 3) + spec.paragraphs + spec.codeFences,
142-
`03: parse cadence capped by the 50ms tier even inside fences (${r.markdownParses} parses / ${r.frames} frames)`,
163+
r.answerMarkdownParses <= Math.ceil(r.frames / 3) + spec.paragraphs + spec.codeFences,
164+
`03: parse cadence capped by the 50ms tier even inside fences (${r.answerMarkdownParses} parses / ${r.frames} frames)`,
143165
);
144166
}
145167

@@ -168,6 +190,7 @@ const scenario = (id: string): UIPerfScenario => {
168190
{
169191
const r = simulate(scenario("UI-PERF-06"));
170192
eq(r.bumpSkipViolations, 0, "06: background streaming never trips the whole-App bump — liveStore only");
193+
eq(r.reasoningMarkdownParses, 0, "06: background reasoning performs no Markdown parsing");
171194
}
172195

173196
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);

desktop/frontend/src/components/Markdown.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,13 @@ export function useRenderedMarkdownText(text: string, streaming: boolean): strin
191191
cancelFinalizationRef.current?.();
192192
cancelFinalizationRef.current = null;
193193
finalizingTextRef.current = null;
194+
// A bounded live preview occasionally advances its window and drops an
195+
// old prefix. Discard the stale parsed tree before paint; the complete
196+
// replacement stays visible through StreamingMarkdownTail and is parsed
197+
// later under the normal adaptive budget.
198+
if (renderedText !== "" && !text.startsWith(renderedText)) {
199+
setRenderedText("");
200+
}
194201
return;
195202
}
196203
lastCommitAtRef.current = 0;

desktop/frontend/src/components/Message.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { useT } from "../lib/i18n";
1313
import { ImageViewer } from "./ImageViewer";
1414
import { Tooltip } from "./Tooltip";
1515
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
16-
import { displayReasoningText } from "../lib/reasoningDisplay";
16+
import { displayReasoningText, STREAMING_REASONING_WINDOW_STEP_CHARS, STREAMING_REASONING_WINDOW_STEP_LINES } from "../lib/reasoningDisplay";
1717
import { stripMemoryCompilerExecution } from "../lib/memoryCompilerDisplay";
1818
import { visibleTranscriptMemoryCitations } from "../lib/memoryCitationVisibility";
1919
import { invocationSegmentsFromMessage, type InvocationMetadataMap } from "../lib/invocationDisplay";
@@ -914,8 +914,8 @@ function ReasoningPanel({
914914
const isReasoningRunning = item.streaming && !item.reasoningComplete;
915915
const visibleReasoning = reasoningOpen
916916
? displayReasoningText(item.reasoning, {
917-
streaming: item.streaming,
918-
truncateStreaming: truncateStreamingReasoning,
917+
streaming: isReasoningRunning,
918+
truncateStreaming: truncateStreamingReasoning, stableWindowChars: STREAMING_REASONING_WINDOW_STEP_CHARS, stableWindowLines: STREAMING_REASONING_WINDOW_STEP_LINES,
919919
})
920920
: "";
921921
const label = isReasoningRunning ? t("msg.thinkingRunning") : t("msg.thinking");
@@ -936,7 +936,7 @@ function ReasoningPanel({
936936
<ChevronRight className={`reasoning__chevron${reasoningOpen ? " reasoning__chevron--open" : ""}`} size={12} />
937937
</button>
938938
{reasoningOpen && (
939-
<div ref={reasoningBodyRef} className="reasoning__body">{visibleReasoning}</div>
939+
<div ref={reasoningBodyRef} className="reasoning__body"><Markdown text={visibleReasoning} streaming={isReasoningRunning} /></div>
940940
)}
941941
</div>
942942
);

desktop/frontend/src/components/ToolCard.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useGSAPCollapse } from "../lib/useGSAPCollapse";
99
import { isTerminalSubagentPhase, type Item, type SubagentPhase } from "../lib/useController";
1010
import type { Translator } from "../lib/i18n";
1111
import { ReadOnlyBatch } from "./ReadOnlyBatch";
12+
import { Markdown } from "./Markdown";
1213

1314
type ToolItem = Extract<Item, { kind: "tool" }>;
1415

@@ -357,7 +358,13 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
357358
{sp.reasoning && (
358359
<div className="tool__subagent-preview-section">
359360
<div className="tool__subagent-preview-label">{t("subagent.preview.reasoning")}</div>
360-
<pre className="tool__subagent-preview-text">{sp.reasoning}</pre>
361+
{open ? (
362+
<div className="tool__subagent-preview-text tool__subagent-preview-text--markdown">
363+
<Markdown text={sp.reasoning} streaming={sp.phase === "reasoning"} />
364+
</div>
365+
) : (
366+
<pre className="tool__subagent-preview-text">{sp.reasoning}</pre>
367+
)}
361368
</div>
362369
)}
363370
{sp.text && (

desktop/frontend/src/components/Transcript.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ import { useEntranceAnimation } from "../lib/useEntranceAnimation";
1919
import { useScrollManager } from "../lib/useScrollManager";
2020
import { buildTurnGroups, compactQuestionText, createWarmLayerState, lastQuestionTurn, questionAnchorId, questionTurnsById, scrollVersion, warmColdPageForTurn, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination, warmUserPreview, type QuestionAnchor, type TurnGroup, type WarmLayerState } from "../lib/transcriptGrouping";
2121
import { appendTurnActionCopyText } from "../lib/turnActionCopy";
22-
import { displayReasoningText } from "../lib/reasoningDisplay";
22+
import { displayReasoningText, STREAMING_REASONING_WINDOW_STEP_CHARS, STREAMING_REASONING_WINDOW_STEP_LINES } from "../lib/reasoningDisplay";
2323
import { observeScrollContentSize } from "../lib/scrollContentObserver";
24+
import { Markdown } from "./Markdown";
2425

2526
type ToolItem = Extract<Item, { kind: "tool" }>;
2627
type AssistantItem = Extract<Item, { kind: "assistant" }>;
@@ -81,7 +82,7 @@ const LiveAssistantMessage = memo(function LiveAssistantMessage({
8182
);
8283
});
8384

84-
function InlineAssistantReasoning({ item }: { item: AssistantItem }) {
85+
function InlineAssistantReasoning({ item, active }: { item: AssistantItem; active: boolean }) {
8586
const t = useT();
8687
const live = useContext(LiveStreamContext);
8788
const [open, setOpen] = useState(true);
@@ -94,13 +95,12 @@ function InlineAssistantReasoning({ item }: { item: AssistantItem }) {
9495
reasoningComplete: live.reasoningComplete,
9596
}
9697
: item;
97-
const reasoning = shown.reasoning.trim();
98+
const reasoning = shown.reasoning.trim(); const running = shown.streaming && !shown.reasoningComplete;
9899
if (!reasoning) return null;
99100
const visibleReasoning = displayReasoningText(shown.reasoning, {
100-
streaming: shown.streaming,
101-
truncateStreaming: true,
101+
streaming: running,
102+
truncateStreaming: true, stableWindowChars: STREAMING_REASONING_WINDOW_STEP_CHARS, stableWindowLines: STREAMING_REASONING_WINDOW_STEP_LINES,
102103
});
103-
const running = shown.streaming && !shown.reasoningComplete;
104104
return (
105105
<div className={`turn-collapse__reasoning-phase${open ? " turn-collapse__reasoning-phase--open" : ""}`}>
106106
<button
@@ -114,7 +114,7 @@ function InlineAssistantReasoning({ item }: { item: AssistantItem }) {
114114
<span>{running ? t("msg.thinkingRunning") : t("msg.thinking")}</span>
115115
<ChevronRight className={`reasoning__chevron${open ? " reasoning__chevron--open" : ""}`} size={12} />
116116
</button>
117-
<div ref={bodyRef} className="turn-collapse__inline-reasoning">{visibleReasoning}</div>
117+
<div ref={bodyRef} className="turn-collapse__inline-reasoning">{active && open ? <Markdown text={visibleReasoning} streaming={running} /> : visibleReasoning}</div>
118118
</div>
119119
);
120120
}
@@ -1525,7 +1525,7 @@ function TurnCollapse({ items, durationMs, mode, subcalls, tabId, creationMode =
15251525
case "assistant":
15261526
// Answer text renders outside the fold (partitionTurnItems strips it),
15271527
// so the fold only ever shows the reasoning segment.
1528-
body.push(<InlineAssistantReasoning key={`${it.id}-reasoning`} item={it as AssistantItem} />);
1528+
body.push(<InlineAssistantReasoning key={`${it.id}-reasoning`} item={it as AssistantItem} active={open} />);
15291529
break;
15301530
}
15311531
}

0 commit comments

Comments
 (0)