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..69558347e --- /dev/null +++ b/packages/ui/src/features/editor/components/useSmoothedText.test.ts @@ -0,0 +1,97 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nextRevealLength, useSmoothedText } from "./useSmoothedText"; + +describe("nextRevealLength", () => { + it.each<[string, number, number, number, number, number]>([ + // label current target elapsed rate expected + ["caught up -> target", 10, 10, 16, 120, 10], + ["past target -> clamps to target", 12, 10, 16, 120, 10], + ["120 chars/sec over 100ms -> +12", 0, 100, 100, 120, 12], + ["never overshoots the target", 95, 100, 1000, 120, 100], + ["always advances at least one when behind", 0, 100, 0, 120, 1], + ["snaps when lag exceeds the cap", 0, 5000, 16, 120, 5000], + ])("%s", (_label, current, target, elapsedMs, rate, expected) => { + expect(nextRevealLength(current, target, elapsedMs, rate)).toBe(expected); + }); +}); + +describe("useSmoothedText", () => { + let now: number; + let rafCallbacks: Array<(t: number) => void>; + + beforeEach(() => { + now = 0; + rafCallbacks = []; + vi.stubGlobal( + "requestAnimationFrame", + (cb: (t: number) => void): number => { + rafCallbacks.push(cb); + return rafCallbacks.length; + }, + ); + vi.stubGlobal("cancelAnimationFrame", () => {}); + vi.stubGlobal("matchMedia", () => ({ matches: false })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const flushFrame = (deltaMs: number) => { + now += deltaMs; + const callbacks = rafCallbacks; + rafCallbacks = []; + act(() => { + for (const cb of callbacks) cb(now); + }); + }; + + it("shows existing text immediately on mount (no replay)", () => { + const { result } = renderHook(() => useSmoothedText("already here")); + expect(result.current).toBe("already here"); + }); + + it("reveals appended text gradually at a steady rate, then catches up", () => { + const { result, rerender } = renderHook( + ({ t }) => useSmoothedText(t, 100), + { initialProps: { t: "" } }, + ); + rerender({ t: "x".repeat(50) }); + + flushFrame(0); // establish the clock; minimal forward progress + expect(result.current.length).toBe(1); + + flushFrame(100); // 100ms at 100 chars/sec -> ~10 more chars + expect(result.current.length).toBe(11); + expect(result.current.length).toBeLessThan(50); + + flushFrame(1000); // plenty of time -> caught up + expect(result.current).toBe("x".repeat(50)); + }); + + it("snaps when the target is replaced with a shorter value", () => { + const { result, rerender } = renderHook( + ({ t }) => useSmoothedText(t, 100), + { + initialProps: { t: "hello world, a longer streamed message" }, + }, + ); + rerender({ t: "new" }); + expect(result.current).toBe("new"); + }); + + it("snaps immediately when reduced motion is preferred", () => { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query.includes("reduce"), + })); + const { result, rerender } = renderHook( + ({ t }) => useSmoothedText(t, 100), + { + initialProps: { t: "" }, + }, + ); + rerender({ t: "x".repeat(50) }); + expect(result.current).toBe("x".repeat(50)); + }); +}); 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..1fb4a9fa1 --- /dev/null +++ b/packages/ui/src/features/editor/components/useSmoothedText.ts @@ -0,0 +1,105 @@ +import { useEffect, useRef, useState } from "react"; + +// Reveal at a steady character rate (~120 chars/sec) rather than an adaptive +// one, so the cadence reads as even typing instead of speeding up to clear a +// backlog. Matches the feel of #2685. See https://upstash.com/blog/smooth-streaming. +const DEFAULT_CHARS_PER_SECOND = 120; +// Past this backlog we stop easing and snap, so a large buffered chunk (e.g. a +// reconnect replaying a long message) never crawls. +const MAX_LAG_CHARS = 600; + +/** + * Pure easing: the next reveal length given how much time elapsed since the last + * frame. Timer-free so it's unit-testable. Never exceeds `target`, never goes + * backwards, and snaps when too far behind to ease smoothly. + */ +export function nextRevealLength( + current: number, + target: number, + elapsedMs: number, + charsPerSecond: number, +): number { + if (current >= target) return target; + if (target - current > MAX_LAG_CHARS) return target; + const step = Math.ceil((charsPerSecond * elapsedMs) / 1000); + return Math.min(target, current + Math.max(step, 1)); +} + +function prefersReducedMotion(): boolean { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +/** + * Smoothly reveals `target` a few characters per frame at a steady rate instead + * of jumping whenever streamed tokens arrive in bursts, so the text reads as + * even typing. Text already present on mount shows immediately (no replay); the + * reveal snaps when the source is replaced with a shorter value (a new message) + * or the user prefers reduced motion. + */ +export function useSmoothedText( + target: string, + charsPerSecond = DEFAULT_CHARS_PER_SECOND, +): string { + const [, forceRender] = useState(0); + const shownLenRef = useRef(target.length); + const targetRef = useRef(target); + targetRef.current = target; + const lastTsRef = useRef(null); + const rafRef = useRef(null); + + // New/replaced (shorter) message: snap, and never hide already-shown text. + if (target.length < shownLenRef.current) { + shownLenRef.current = target.length; + } + + useEffect(() => { + if (prefersReducedMotion()) { + if (shownLenRef.current !== targetRef.current.length) { + shownLenRef.current = targetRef.current.length; + forceRender((n) => (n + 1) % 1_000_000); + } + return; + } + // Kick the reveal loop only if it's idle. While running it reads the latest + // target each frame (via ref), so it keeps a steady wall-clock rate across + // token appends instead of restarting — and resetting its clock — per token. + if (rafRef.current === null && shownLenRef.current < target.length) { + lastTsRef.current = null; + const tick = (ts: number) => { + const last = lastTsRef.current ?? ts; + lastTsRef.current = ts; + shownLenRef.current = nextRevealLength( + shownLenRef.current, + targetRef.current.length, + ts - last, + charsPerSecond, + ); + forceRender((n) => (n + 1) % 1_000_000); + if (shownLenRef.current < targetRef.current.length) { + rafRef.current = requestAnimationFrame(tick); + } else { + rafRef.current = null; + lastTsRef.current = null; + } + }; + rafRef.current = requestAnimationFrame(tick); + } + }, [target, charsPerSecond]); + + // Cancel any in-flight frame on unmount (kept separate so token appends don't + // tear down the running loop). + useEffect( + () => () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + }, + [], + ); + + 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" ? (