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 000000000..eb268cb21 --- /dev/null +++ b/packages/ui/src/features/editor/components/StreamingMarkdown.tsx @@ -0,0 +1,64 @@ +import { memo, useMemo } from "react"; +import type { Components } from "react-markdown"; +import { MarkdownRenderer } from "./MarkdownRenderer"; +import { + hasOpenCodeFence, + parseOpenFence, + splitMarkdownBlocks, +} from "./splitMarkdownBlocks"; + +interface StreamingMarkdownProps { + content: string; + componentsOverride?: Partial; +} + +/** + * 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 000000000..67346f61c --- /dev/null +++ b/packages/ui/src/features/editor/components/splitMarkdownBlocks.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + hasOpenCodeFence, + parseOpenFence, + splitMarkdownBlocks, +} from "./splitMarkdownBlocks"; + +describe("splitMarkdownBlocks", () => { + 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", () => { + 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"; + expect(splitMarkdownBlocks(md)).toEqual([ + "```\nline1\n\nline2\n```\n\n", + "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.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 new file mode 100644 index 000000000..2d31862b6 --- /dev/null +++ b/packages/ui/src/features/editor/components/splitMarkdownBlocks.ts @@ -0,0 +1,107 @@ +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 + * 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 fence = NO_FENCE; + + while (i < n) { + let nl = src.indexOf("\n", i); + if (nl === -1) nl = n; + const line = src.slice(i, nl); + fence = stepFence(fence, line); + const lineEnd = nl < n ? nl + 1 : n; + if (line.trim() === "" && !fence.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 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; + } + + 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 }; +} 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 000000000..9d731ff02 --- /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 000000000..2bd5849d6 --- /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 59fd2f6d1..462258291 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,8 @@ 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 { useSmoothedText } from "../../../editor/components/useSmoothedText"; import { usePanelLayoutStore } from "../../../panels/panelLayoutStore"; import type { FileItem } from "../../../repo-files/useRepoFiles"; import { useRepoFiles } from "../../../repo-files/useRepoFiles"; @@ -138,12 +140,18 @@ const agentComponents: Partial = { interface AgentMessageProps { content: string; + /** 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; } export const AgentMessage = memo(function AgentMessage({ content, + isStreaming = false, }: AgentMessageProps) { const [copied, setCopied] = useState(false); + const smoothed = useSmoothedText(content); const handleCopy = useCallback(() => { navigator.clipboard.writeText(content); @@ -153,10 +161,17 @@ export const AgentMessage = memo(function AgentMessage({ return ( - + {isStreaming ? ( + + ) : ( + + )} + ) : null; case "agent_thought_chunk": return item.content.type === "text" ? (