Skip to content

Commit ab30fde

Browse files
fix(web): first expansion of a long reasoning trace no longer blocks the click
useDeferredValue returns the full value on mount, so expanding a long trace still parsed it in the click's commit. Start from an empty list so the first full parse runs in a deferred render. Also keep the preview cut from splitting an emoji when a trace has no whitespace, and cover the preview helpers with unit tests. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 15d481d commit ab30fde

3 files changed

Lines changed: 42 additions & 2 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
import { parseMarkdownWithMath } from "@t3tools/shared/markdownMath";
3+
import { remarkThoughtPreview, thoughtPreviewSource } from "./TimelineWorkEntryRow.logic";
4+
5+
describe("thoughtPreviewSource", () => {
6+
it("keeps short traces intact", () => {
7+
expect(thoughtPreviewSource("Checking the parser.")).toBe("Checking the parser.");
8+
});
9+
10+
it("cuts long traces at a word boundary", () => {
11+
const source = thoughtPreviewSource(`${"word ".repeat(1_000)}tail`);
12+
expect(source.length).toBeLessThanOrEqual(2_000);
13+
expect(source.endsWith("word")).toBe(true);
14+
});
15+
16+
it("never cuts inside an emoji when the trace has no whitespace", () => {
17+
expect(thoughtPreviewSource(`${"a".repeat(1_999)}🙂${"b".repeat(10)}`)).toBe("a".repeat(1_999));
18+
});
19+
});
20+
21+
describe("remarkThoughtPreview", () => {
22+
it("flattens a long trace into one collapsed text run", () => {
23+
const tree = parseMarkdownWithMath(`**Plan**\n\n${"Read `a.ts` and *b*.\n\n".repeat(2_000)}`);
24+
remarkThoughtPreview("Thought")(tree);
25+
expect(tree.children).toHaveLength(1);
26+
const [text] = tree.children;
27+
expect(text?.type === "text" && text.value.startsWith("Plan Read a.ts and b. Read")).toBe(true);
28+
expect(text?.type === "text" && /\s{2}/.test(text.value)).toBe(false);
29+
});
30+
31+
it("falls back when the trace has no visible text", () => {
32+
const tree = parseMarkdownWithMath("<b></b>");
33+
remarkThoughtPreview("Thought")(tree);
34+
expect(tree.children).toEqual([{ type: "text", value: "Thought" }]);
35+
});
36+
});

‎apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,8 @@ export function thoughtPreviewSource(text: string): string {
196196
if (text.length <= THOUGHT_PREVIEW_MAX_CHARS) return text;
197197
const head = text.slice(0, THOUGHT_PREVIEW_MAX_CHARS);
198198
const boundary = head.search(/\s\S*$/);
199-
return boundary > 0 ? head.slice(0, boundary) : head;
199+
// Without whitespace, drop a lone high surrogate so the cut never splits an emoji.
200+
return boundary > 0 ? head.slice(0, boundary) : head.replace(/[\uD800-\uDBFF]$/, "");
200201
}
201202

202203
/** Compact reasoning keeps formulas while flattening other Markdown into label text. */

‎apps/web/src/components/chat/TimelineWorkEntryRow.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,15 @@ const toolCallExpandedBodyClassName =
115115

116116
const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation();
117117

118+
const NO_REASONING_ENTRIES: ReadonlyArray<TimelineWorkEntry> = [];
119+
118120
export function ReasoningTraceContent({ entries }: { entries: ReadonlyArray<TimelineWorkEntry> }) {
119121
const ctx = use(TimelineRowCtx);
120122
const { isWorking, latestRunId } = use(TimelineRowActivityCtx);
121123
// Long traces re-parse on every delta; deferring lets newer deltas interrupt a stale render.
122124
// Defer the whole list so the commit that grows the text also reruns live-follow scrolling.
123-
const deferredEntries = useDeferredValue(entries);
125+
// The empty initial value moves the first full parse out of the expanding click's commit.
126+
const deferredEntries = useDeferredValue(entries, NO_REASONING_ENTRIES);
124127
const isStreaming = (entry: TimelineWorkEntry) =>
125128
isWorking && entry.runId === latestRunId && entry.toolLifecycleStatus === "inProgress";
126129
return (

0 commit comments

Comments
 (0)