From baa62f79968687e73047255eafe7c514b51da606 Mon Sep 17 00:00:00 2001 From: karlo Date: Tue, 16 Jun 2026 15:03:37 -0700 Subject: [PATCH 1/3] perf(markdown): re-parse only the tail block while streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each streamed token re-parsed the entire accumulated markdown of the active agent message (react-markdown + remark-gfm) — O(n) per token, O(n^2) per message. Past ~8k chars a single token costs ~19ms of parsing, over the 16.6ms frame budget, so long answers stutter and saturate the main thread. StreamingMarkdown splits the active message into top-level blocks (fenced code kept intact). Completed blocks keep a stable string so the memoized MarkdownRenderer skips them and only the growing tail re-parses; an open code fence renders as plain text until it closes, so syntax highlighting runs once rather than per token. On a 3k-char answer this is ~10x less markdown CPU (878ms -> 88ms total; 3.5ms -> 0.35ms per token) and the per-token cost stays flat instead of growing with message length. Completed messages still render via a single full MarkdownRenderer parse, so output is byte-identical — no visual change, only less work per token. Complements #2685 (smooth token reveal): that PR eases when text appears, this one keeps rendering it cheap so the smooth reveal stays at 60fps even on long messages. Generated-By: PostHog Code Task-Id: 8e9f327d-84b1-4608-9f48-c3038dbf87ca --- .../editor/components/StreamingMarkdown.tsx | 74 +++++++++++++++++++ .../components/splitMarkdownBlocks.test.ts | 45 +++++++++++ .../editor/components/splitMarkdownBlocks.ts | 74 +++++++++++++++++++ .../session-update/AgentMessage.tsx | 21 +++++- .../session-update/SessionUpdateView.tsx | 5 +- 5 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/features/editor/components/StreamingMarkdown.tsx create mode 100644 packages/ui/src/features/editor/components/splitMarkdownBlocks.test.ts create mode 100644 packages/ui/src/features/editor/components/splitMarkdownBlocks.ts diff --git a/packages/ui/src/features/editor/components/StreamingMarkdown.tsx b/packages/ui/src/features/editor/components/StreamingMarkdown.tsx new file mode 100644 index 0000000000..95168c7aae --- /dev/null +++ b/packages/ui/src/features/editor/components/StreamingMarkdown.tsx @@ -0,0 +1,74 @@ +import { memo, useMemo } from "react"; +import type { Components } from "react-markdown"; +import { MarkdownRenderer } from "./MarkdownRenderer"; +import { hasOpenCodeFence, splitMarkdownBlocks } from "./splitMarkdownBlocks"; + +interface StreamingMarkdownProps { + content: string; + componentsOverride?: Partial; +} + +/** + * Split a block whose code fence is still open into the prose that precedes the + * fence and the code accumulated so far (the opening ```lang line removed). + */ +function parseOpenFence(block: string): { before: string; code: string } { + const m = /(^|\n) {0,3}(`{3,}|~{3,})/.exec(block); + if (!m) return { before: "", code: block }; + const fenceLineStart = m.index + (block[m.index] === "\n" ? 1 : 0); + const before = block.slice(0, fenceLineStart); + const afterMarker = block.indexOf("\n", fenceLineStart); + const code = afterMarker === -1 ? "" : block.slice(afterMarker + 1); + return { before, code }; +} + +/** + * Renders streamed agent markdown without re-parsing the whole message on every + * token. The text is split into top-level blocks: completed blocks keep a stable + * string so the memoized {@link MarkdownRenderer} skips re-parsing them, and only + * the growing tail is re-parsed — turning the per-token cost from O(message) into + * O(last block). + * + * While the tail sits inside an unterminated code fence it's shown as plain + * monospace (no markdown parse, no syntax highlighting); the heavy highlight runs + * once, when the fence closes and the block freezes. Completed messages should + * use {@link MarkdownRenderer} directly for a single, fully-correct parse. + */ +export const StreamingMarkdown = memo(function StreamingMarkdown({ + content, + componentsOverride, +}: StreamingMarkdownProps) { + const blocks = useMemo(() => splitMarkdownBlocks(content), [content]); + const lastIndex = blocks.length - 1; + + return ( + <> + {blocks.map((block, index) => { + const key = `b${index}`; + if (index === lastIndex && hasOpenCodeFence(block)) { + const { before, code } = parseOpenFence(block); + return ( +
+ {before.trim() ? ( + + ) : null} +
+                {code}
+              
+
+ ); + } + return ( + + ); + })} + + ); +}); diff --git a/packages/ui/src/features/editor/components/splitMarkdownBlocks.test.ts b/packages/ui/src/features/editor/components/splitMarkdownBlocks.test.ts new file mode 100644 index 0000000000..0a56616221 --- /dev/null +++ b/packages/ui/src/features/editor/components/splitMarkdownBlocks.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { hasOpenCodeFence, splitMarkdownBlocks } from "./splitMarkdownBlocks"; + +describe("splitMarkdownBlocks", () => { + it("never drops text — joining the blocks reproduces the input", () => { + const samples = [ + "", + "single line", + "para one\n\npara two\n\npara three", + "# Heading\n\nText with **bold**.\n\n- a\n- b\n", + "Intro\n\n```ts\nconst x = 1;\nconst y = 2;\n```\n\nOutro", + "trailing blanks\n\n\n\n", + ]; + for (const s of samples) { + expect(splitMarkdownBlocks(s).join("")).toBe(s); + } + }); + + it("splits paragraphs at blank lines", () => { + expect(splitMarkdownBlocks("a\n\nb\n\nc")).toEqual(["a\n\n", "b\n\n", "c"]); + }); + + it("keeps a fenced code block (with blank lines inside) as one block", () => { + const md = "```\nline1\n\nline2\n```\n\nafter"; + const blocks = splitMarkdownBlocks(md); + expect(blocks[0]).toBe("```\nline1\n\nline2\n```\n\n"); + expect(blocks[1]).toBe("after"); + }); + + it("does not split inside an unterminated fence (the tail stays whole)", () => { + const md = "intro\n\n```ts\nconst a = 1;\n\nconst b = 2;"; + const blocks = splitMarkdownBlocks(md); + expect(blocks[blocks.length - 1]).toContain("const b = 2;"); + expect(blocks.join("")).toBe(md); + }); +}); + +describe("hasOpenCodeFence", () => { + it("is true while a fence is open and false once it closes", () => { + expect(hasOpenCodeFence("```ts\nconst a = 1;")).toBe(true); + expect(hasOpenCodeFence("```ts\nconst a = 1;\n```")).toBe(false); + expect(hasOpenCodeFence("no code here")).toBe(false); + expect(hasOpenCodeFence("text\n\n```\npartial")).toBe(true); + }); +}); diff --git a/packages/ui/src/features/editor/components/splitMarkdownBlocks.ts b/packages/ui/src/features/editor/components/splitMarkdownBlocks.ts new file mode 100644 index 0000000000..045c335c2e --- /dev/null +++ b/packages/ui/src/features/editor/components/splitMarkdownBlocks.ts @@ -0,0 +1,74 @@ +/** + * Split append-only markdown into top-level blocks at blank-line boundaries, + * keeping fenced code blocks intact. Concatenating the result reproduces the + * input exactly, so no text is ever dropped. + * + * During streaming the LAST element is the still-growing "tail"; everything + * before it is stable (append-only text never rewrites an earlier block), so a + * caller can render earlier blocks once and memoize them, re-parsing only the + * tail on each token. That turns the per-token markdown cost from O(message) + * into O(last block). + */ +export function splitMarkdownBlocks(src: string): string[] { + if (src.length === 0) return [src]; + const blocks: string[] = []; + const n = src.length; + let blockStart = 0; + let i = 0; + let inFence = false; + let fenceChar = ""; + + while (i < n) { + let nl = src.indexOf("\n", i); + if (nl === -1) nl = n; + const line = src.slice(i, nl); + const trimmed = line.replace(/^ {0,3}/, ""); + const fence = /^(`{3,}|~{3,})/.exec(trimmed); + if (fence) { + if (!inFence) { + inFence = true; + fenceChar = fence[1][0]; + } else if (trimmed[0] === fenceChar) { + inFence = false; + } + } + const lineEnd = nl < n ? nl + 1 : n; + if (line.trim() === "" && !inFence) { + // Fold any following blank lines into this same boundary so we don't emit + // empty blocks. + let j = lineEnd; + while (j < n) { + let nl2 = src.indexOf("\n", j); + if (nl2 === -1) nl2 = n; + if (src.slice(j, nl2).trim() !== "") break; + j = nl2 < n ? nl2 + 1 : n; + } + blocks.push(src.slice(blockStart, j)); + blockStart = j; + i = j; + } else { + i = lineEnd; + } + } + + if (blockStart < n) blocks.push(src.slice(blockStart)); + return blocks.length > 0 ? blocks : [src]; +} + +/** True when `src` ends inside an unterminated fenced code block. */ +export function hasOpenCodeFence(src: string): boolean { + let inFence = false; + let fenceChar = ""; + for (const line of src.split("\n")) { + const trimmed = line.replace(/^ {0,3}/, ""); + const fence = /^(`{3,}|~{3,})/.exec(trimmed); + if (!fence) continue; + if (!inFence) { + inFence = true; + fenceChar = fence[1][0]; + } else if (trimmed[0] === fenceChar) { + inFence = false; + } + } + return inFence; +} diff --git a/packages/ui/src/features/sessions/components/session-update/AgentMessage.tsx b/packages/ui/src/features/sessions/components/session-update/AgentMessage.tsx index b442be5354..7ace13a762 100644 --- a/packages/ui/src/features/sessions/components/session-update/AgentMessage.tsx +++ b/packages/ui/src/features/sessions/components/session-update/AgentMessage.tsx @@ -6,6 +6,7 @@ import { HighlightedCode } from "../../../../primitives/HighlightedCode"; import { Tooltip } from "../../../../primitives/Tooltip"; import { usePendingScrollStore } from "../../../code-editor/pendingScrollStore"; import { MarkdownRenderer } from "../../../editor/components/MarkdownRenderer"; +import { StreamingMarkdown } from "../../../editor/components/StreamingMarkdown"; import { usePanelLayoutStore } from "../../../panels/panelLayoutStore"; import type { FileItem } from "../../../repo-files/useRepoFiles"; import { useRepoFiles } from "../../../repo-files/useRepoFiles"; @@ -138,10 +139,15 @@ const agentComponents: Partial = { interface AgentMessageProps { content: string; + /** Active (still-streaming) message: block-split the markdown so each token + * only re-parses the growing tail, not the whole message. Completed messages + * parse once via MarkdownRenderer for a single, fully-correct render. */ + isStreaming?: boolean; } export const AgentMessage = memo(function AgentMessage({ content, + isStreaming = false, }: AgentMessageProps) { const [copied, setCopied] = useState(false); @@ -153,10 +159,17 @@ export const AgentMessage = memo(function AgentMessage({ return ( - + {isStreaming ? ( + + ) : ( + + )} + ) : null; case "agent_thought_chunk": return item.content.type === "text" ? ( From 3f6bc14132a262e3ebaa7ad7c98b0996332d7e20 Mon Sep 17 00:00:00 2001 From: karlo Date: Tue, 16 Jun 2026 15:32:13 -0700 Subject: [PATCH 2/3] =?UTF-8?q?refactor(markdown):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20shared=20fence=20tracker,=20last-open=20fence,=20it?= =?UTF-8?q?.each?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract a single stepFence state machine shared by splitMarkdownBlocks, hasOpenCodeFence and parseOpenFence (OnceAndOnlyOnce — no duplicated fence-tracking logic). - parseOpenFence now targets the LAST unterminated fence, so a completed fence earlier in the same block stays in `before` and renders normally instead of being swallowed as plain text mid-stream. - Parameterize the splitter tests with it.each and add parseOpenFence cases, including the completed-then-open fence edge case. Generated-By: PostHog Code Task-Id: 8e9f327d-84b1-4608-9f48-c3038dbf87ca --- .../editor/components/StreamingMarkdown.tsx | 20 ++--- .../components/splitMarkdownBlocks.test.ts | 63 +++++++++----- .../editor/components/splitMarkdownBlocks.ts | 85 +++++++++++++------ 3 files changed, 106 insertions(+), 62 deletions(-) diff --git a/packages/ui/src/features/editor/components/StreamingMarkdown.tsx b/packages/ui/src/features/editor/components/StreamingMarkdown.tsx index 95168c7aae..eb268cb219 100644 --- a/packages/ui/src/features/editor/components/StreamingMarkdown.tsx +++ b/packages/ui/src/features/editor/components/StreamingMarkdown.tsx @@ -1,27 +1,17 @@ import { memo, useMemo } from "react"; import type { Components } from "react-markdown"; import { MarkdownRenderer } from "./MarkdownRenderer"; -import { hasOpenCodeFence, splitMarkdownBlocks } from "./splitMarkdownBlocks"; +import { + hasOpenCodeFence, + parseOpenFence, + splitMarkdownBlocks, +} from "./splitMarkdownBlocks"; interface StreamingMarkdownProps { content: string; componentsOverride?: Partial; } -/** - * Split a block whose code fence is still open into the prose that precedes the - * fence and the code accumulated so far (the opening ```lang line removed). - */ -function parseOpenFence(block: string): { before: string; code: string } { - const m = /(^|\n) {0,3}(`{3,}|~{3,})/.exec(block); - if (!m) return { before: "", code: block }; - const fenceLineStart = m.index + (block[m.index] === "\n" ? 1 : 0); - const before = block.slice(0, fenceLineStart); - const afterMarker = block.indexOf("\n", fenceLineStart); - const code = afterMarker === -1 ? "" : block.slice(afterMarker + 1); - return { before, code }; -} - /** * Renders streamed agent markdown without re-parsing the whole message on every * token. The text is split into top-level blocks: completed blocks keep a stable diff --git a/packages/ui/src/features/editor/components/splitMarkdownBlocks.test.ts b/packages/ui/src/features/editor/components/splitMarkdownBlocks.test.ts index 0a56616221..67346f61c4 100644 --- a/packages/ui/src/features/editor/components/splitMarkdownBlocks.test.ts +++ b/packages/ui/src/features/editor/components/splitMarkdownBlocks.test.ts @@ -1,19 +1,20 @@ import { describe, expect, it } from "vitest"; -import { hasOpenCodeFence, splitMarkdownBlocks } from "./splitMarkdownBlocks"; +import { + hasOpenCodeFence, + parseOpenFence, + splitMarkdownBlocks, +} from "./splitMarkdownBlocks"; describe("splitMarkdownBlocks", () => { - it("never drops text — joining the blocks reproduces the input", () => { - const samples = [ - "", - "single line", - "para one\n\npara two\n\npara three", - "# Heading\n\nText with **bold**.\n\n- a\n- b\n", - "Intro\n\n```ts\nconst x = 1;\nconst y = 2;\n```\n\nOutro", - "trailing blanks\n\n\n\n", - ]; - for (const s of samples) { - expect(splitMarkdownBlocks(s).join("")).toBe(s); - } + it.each([ + "", + "single line", + "para one\n\npara two\n\npara three", + "# Heading\n\nText with **bold**.\n\n- a\n- b\n", + "Intro\n\n```ts\nconst x = 1;\nconst y = 2;\n```\n\nOutro", + "trailing blanks\n\n\n\n", + ])("joins back to the exact input, dropping no text: %j", (src) => { + expect(splitMarkdownBlocks(src).join("")).toBe(src); }); it("splits paragraphs at blank lines", () => { @@ -22,9 +23,10 @@ describe("splitMarkdownBlocks", () => { it("keeps a fenced code block (with blank lines inside) as one block", () => { const md = "```\nline1\n\nline2\n```\n\nafter"; - const blocks = splitMarkdownBlocks(md); - expect(blocks[0]).toBe("```\nline1\n\nline2\n```\n\n"); - expect(blocks[1]).toBe("after"); + expect(splitMarkdownBlocks(md)).toEqual([ + "```\nline1\n\nline2\n```\n\n", + "after", + ]); }); it("does not split inside an unterminated fence (the tail stays whole)", () => { @@ -36,10 +38,29 @@ describe("splitMarkdownBlocks", () => { }); describe("hasOpenCodeFence", () => { - it("is true while a fence is open and false once it closes", () => { - expect(hasOpenCodeFence("```ts\nconst a = 1;")).toBe(true); - expect(hasOpenCodeFence("```ts\nconst a = 1;\n```")).toBe(false); - expect(hasOpenCodeFence("no code here")).toBe(false); - expect(hasOpenCodeFence("text\n\n```\npartial")).toBe(true); + it.each<[string, boolean]>([ + ["```ts\nconst a = 1;", true], + ["```ts\nconst a = 1;\n```", false], + ["no code here", false], + ["text\n\n```\npartial", true], + ])("%j -> open=%s", (src, expected) => { + expect(hasOpenCodeFence(src)).toBe(expected); + }); +}); + +describe("parseOpenFence", () => { + it("splits the prose before the open fence from the code so far", () => { + const { before, code } = parseOpenFence("Here:\n```ts\nconst a = 1;"); + expect(before).toBe("Here:\n"); + expect(code).toBe("const a = 1;"); + }); + + it("targets the LAST open fence, leaving an earlier completed fence in `before`", () => { + // A completed fence, then text, then an open fence — all one block (no + // blank lines). The earlier fence must not be swallowed into plain text. + const block = "```ts\ndone\n```\ntext\n```ts\npartial"; + const { before, code } = parseOpenFence(block); + expect(before).toBe("```ts\ndone\n```\ntext\n"); + expect(code).toBe("partial"); }); }); diff --git a/packages/ui/src/features/editor/components/splitMarkdownBlocks.ts b/packages/ui/src/features/editor/components/splitMarkdownBlocks.ts index 045c335c2e..2d31862b61 100644 --- a/packages/ui/src/features/editor/components/splitMarkdownBlocks.ts +++ b/packages/ui/src/features/editor/components/splitMarkdownBlocks.ts @@ -1,3 +1,25 @@ +interface FenceState { + inFence: boolean; + fenceChar: string; +} + +const NO_FENCE: FenceState = { inFence: false, fenceChar: "" }; + +/** + * Advance the fenced-code-block state machine by one line. `inFence` flips on a + * ``` / ~~~ delimiter line, closing only when the marker char matches the one + * that opened the fence. Shared by every fence-aware function here so the rule + * lives in exactly one place. + */ +function stepFence(state: FenceState, line: string): FenceState { + const trimmed = line.replace(/^ {0,3}/, ""); + const marker = /^(`{3,}|~{3,})/.exec(trimmed); + if (!marker) return state; + if (!state.inFence) return { inFence: true, fenceChar: marker[1][0] }; + if (trimmed[0] === state.fenceChar) return NO_FENCE; + return state; +} + /** * Split append-only markdown into top-level blocks at blank-line boundaries, * keeping fenced code blocks intact. Concatenating the result reproduces the @@ -15,25 +37,15 @@ export function splitMarkdownBlocks(src: string): string[] { const n = src.length; let blockStart = 0; let i = 0; - let inFence = false; - let fenceChar = ""; + let fence = NO_FENCE; while (i < n) { let nl = src.indexOf("\n", i); if (nl === -1) nl = n; const line = src.slice(i, nl); - const trimmed = line.replace(/^ {0,3}/, ""); - const fence = /^(`{3,}|~{3,})/.exec(trimmed); - if (fence) { - if (!inFence) { - inFence = true; - fenceChar = fence[1][0]; - } else if (trimmed[0] === fenceChar) { - inFence = false; - } - } + fence = stepFence(fence, line); const lineEnd = nl < n ? nl + 1 : n; - if (line.trim() === "" && !inFence) { + if (line.trim() === "" && !fence.inFence) { // Fold any following blank lines into this same boundary so we don't emit // empty blocks. let j = lineEnd; @@ -57,18 +69,39 @@ export function splitMarkdownBlocks(src: string): string[] { /** True when `src` ends inside an unterminated fenced code block. */ export function hasOpenCodeFence(src: string): boolean { - let inFence = false; - let fenceChar = ""; - for (const line of src.split("\n")) { - const trimmed = line.replace(/^ {0,3}/, ""); - const fence = /^(`{3,}|~{3,})/.exec(trimmed); - if (!fence) continue; - if (!inFence) { - inFence = true; - fenceChar = fence[1][0]; - } else if (trimmed[0] === fenceChar) { - inFence = false; - } + let fence = NO_FENCE; + for (const line of src.split("\n")) fence = stepFence(fence, line); + return fence.inFence; +} + +/** + * For a block that ends inside an unterminated code fence, split it into the + * prose/markdown preceding the OPEN fence and the code accumulated so far (the + * opening ```lang line removed). Targets the LAST unterminated fence, so an + * earlier completed fence in the same block stays in `before` and renders + * normally instead of being swallowed as plain text. + */ +export function parseOpenFence(block: string): { + before: string; + code: string; +} { + let fence = NO_FENCE; + let openLineStart = -1; + let i = 0; + const n = block.length; + + while (i < n) { + let nl = block.indexOf("\n", i); + if (nl === -1) nl = n; + const wasInFence = fence.inFence; + fence = stepFence(fence, block.slice(i, nl)); + if (!wasInFence && fence.inFence) openLineStart = i; + i = nl < n ? nl + 1 : n; } - return inFence; + + if (openLineStart === -1) return { before: "", code: block }; + const before = block.slice(0, openLineStart); + const afterMarker = block.indexOf("\n", openLineStart); + const code = afterMarker === -1 ? "" : block.slice(afterMarker + 1); + return { before, code }; } From cfba3d2ee87b4bb374c753bd91ad9feb0a6b4d65 Mon Sep 17 00:00:00 2001 From: karlo Date: Tue, 16 Jun 2026 22:04:40 -0700 Subject: [PATCH 3/3] feat(ui): adaptive typewriter reveal for streamed agent text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smooth the reveal of streamed tokens on top of the block-split renderer. useSmoothedText eases the displayed prefix toward the accumulated text via requestAnimationFrame with a step proportional to the backlog, so it tracks a fast token stream (catching up in ~6 frames) instead of a fixed chars/sec that lags behind and then snaps. It reveals on word boundaries so partial markdown tokens never flash, and shows completed (non-streaming) messages in full immediately. Pairs with the block-split renderer: only the growing tail re-parses each frame, so the reveal holds ~60fps even on long messages — which a fixed-rate reveal over a full markdown re-parse can't. Generated-By: PostHog Code Task-Id: 8e9f327d-84b1-4608-9f48-c3038dbf87ca --- .../editor/components/useSmoothedText.test.ts | 69 +++++++++++++++++ .../editor/components/useSmoothedText.ts | 74 +++++++++++++++++++ .../session-update/AgentMessage.tsx | 10 ++- 3 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/features/editor/components/useSmoothedText.test.ts create mode 100644 packages/ui/src/features/editor/components/useSmoothedText.ts diff --git a/packages/ui/src/features/editor/components/useSmoothedText.test.ts b/packages/ui/src/features/editor/components/useSmoothedText.test.ts new file mode 100644 index 0000000000..9d731ff027 --- /dev/null +++ b/packages/ui/src/features/editor/components/useSmoothedText.test.ts @@ -0,0 +1,69 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useSmoothedText } from "./useSmoothedText"; + +// Manual rAF queue so we can step frames deterministically and honor cancels. +let frames = new Map(); +let nextId = 1; + +beforeEach(() => { + frames = new Map(); + nextId = 1; + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + const id = nextId++; + frames.set(id, cb); + return id; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => { + frames.delete(id); + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function stepFrames(n: number) { + for (let i = 0; i < n; i++) { + const pending = [...frames.values()]; + frames.clear(); + act(() => { + for (const cb of pending) cb(0); + }); + } +} + +describe("useSmoothedText", () => { + it("shows the initial text immediately (no replay on mount)", () => { + const { result } = renderHook(({ t }) => useSmoothedText(t), { + initialProps: { t: "hello" }, + }); + expect(result.current).toBe("hello"); + }); + + it("reveals appended text gradually, then converges to the target", () => { + const target = "hello world this is a longer streamed message indeed"; + const { result, rerender } = renderHook(({ t }) => useSmoothedText(t), { + initialProps: { t: "hello" }, + }); + rerender({ t: target }); + + stepFrames(1); + expect(result.current.length).toBeGreaterThanOrEqual("hello".length); + expect(result.current.length).toBeLessThan(target.length); + + stepFrames(40); + expect(result.current).toBe(target); + }); + + it("always shows a prefix of the target and never overshoots", () => { + const target = "abcdefghijklmnopqrstuvwxyz"; + const { result, rerender } = renderHook(({ t }) => useSmoothedText(t), { + initialProps: { t: "" }, + }); + rerender({ t: target }); + stepFrames(3); + expect(target.startsWith(result.current)).toBe(true); + expect(result.current.length).toBeLessThanOrEqual(target.length); + }); +}); diff --git a/packages/ui/src/features/editor/components/useSmoothedText.ts b/packages/ui/src/features/editor/components/useSmoothedText.ts new file mode 100644 index 0000000000..2bd5849d69 --- /dev/null +++ b/packages/ui/src/features/editor/components/useSmoothedText.ts @@ -0,0 +1,74 @@ +import { useEffect, useRef, useState } from "react"; + +// Reveal the backlog over ~this many frames. The step scales with the backlog, +// so it converges in roughly this many frames regardless of how big a burst +// arrives — small bursts trickle, big bursts catch up fast. +const SMOOTHING_FRAMES = 6; +const MIN_REVEAL = 2; + +/** + * Smoothly reveals `target` a slice per animation frame instead of jumping to + * whatever arrived. Streamed tokens land in irregular bursts (1–4 words, then a + * pause); painting them verbatim looks choppy. This decouples arrival from + * paint so the text flows at a steady ~60fps "typewriter" cadence while never + * lagging far behind — the reveal step is proportional to the remaining + * backlog, so it catches up within ~SMOOTHING_FRAMES frames. + * + * Append-only by design: a shorter `target` (a brand-new message reusing this + * hook instance) snaps instantly and we never hide already-revealed text. + */ +export function useSmoothedText(target: string): string { + const [, forceRender] = useState(0); + const shownLenRef = useRef(target.length); + const targetRef = useRef(target); + targetRef.current = target; + const rafRef = useRef(null); + + // Snap when the text shrinks (new/replaced message) — never un-reveal text. + if (target.length < shownLenRef.current) { + shownLenRef.current = target.length; + } + + useEffect(() => { + const tick = () => { + const tgtLen = targetRef.current.length; + const remaining = tgtLen - shownLenRef.current; + if (remaining <= 0) { + rafRef.current = null; + return; + } + const step = Math.max( + MIN_REVEAL, + Math.ceil(remaining / SMOOTHING_FRAMES), + ); + const shown = shownLenRef.current; + let next = Math.min(tgtLen, shown + step); + if (next < tgtLen) { + // Stop at a whitespace boundary when possible so words (and inline + // markdown tokens like **bold**) reveal whole instead of mid-token. + const text = targetRef.current; + const boundary = Math.max( + text.lastIndexOf(" ", next), + text.lastIndexOf("\n", next), + ); + if (boundary > shown) next = boundary + 1; + } + shownLenRef.current = next; + forceRender((n) => (n + 1) % 1_000_000); + rafRef.current = requestAnimationFrame(tick); + }; + if (rafRef.current === null && shownLenRef.current < target.length) { + rafRef.current = requestAnimationFrame(tick); + } + return () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, [target]); + + return shownLenRef.current >= targetRef.current.length + ? targetRef.current + : targetRef.current.slice(0, shownLenRef.current); +} diff --git a/packages/ui/src/features/sessions/components/session-update/AgentMessage.tsx b/packages/ui/src/features/sessions/components/session-update/AgentMessage.tsx index 7ace13a762..b1b53b123a 100644 --- a/packages/ui/src/features/sessions/components/session-update/AgentMessage.tsx +++ b/packages/ui/src/features/sessions/components/session-update/AgentMessage.tsx @@ -7,6 +7,7 @@ import { Tooltip } from "../../../../primitives/Tooltip"; import { usePendingScrollStore } from "../../../code-editor/pendingScrollStore"; import { MarkdownRenderer } from "../../../editor/components/MarkdownRenderer"; import { StreamingMarkdown } from "../../../editor/components/StreamingMarkdown"; +import { useSmoothedText } from "../../../editor/components/useSmoothedText"; import { usePanelLayoutStore } from "../../../panels/panelLayoutStore"; import type { FileItem } from "../../../repo-files/useRepoFiles"; import { useRepoFiles } from "../../../repo-files/useRepoFiles"; @@ -139,9 +140,9 @@ const agentComponents: Partial = { interface AgentMessageProps { content: string; - /** Active (still-streaming) message: block-split the markdown so each token - * only re-parses the growing tail, not the whole message. Completed messages - * parse once via MarkdownRenderer for a single, fully-correct render. */ + /** Active (still-streaming) message: smooth the reveal and block-split the + * markdown so each token only re-parses the tail. Completed messages parse + * once via MarkdownRenderer for a single, fully-correct render. */ isStreaming?: boolean; } @@ -150,6 +151,7 @@ export const AgentMessage = memo(function AgentMessage({ isStreaming = false, }: AgentMessageProps) { const [copied, setCopied] = useState(false); + const smoothed = useSmoothedText(content); const handleCopy = useCallback(() => { navigator.clipboard.writeText(content); @@ -161,7 +163,7 @@ export const AgentMessage = memo(function AgentMessage({ {isStreaming ? ( ) : (