-
Notifications
You must be signed in to change notification settings - Fork 43
feat(ui): smooth + cheap agent streaming (adaptive reveal + block-split markdown) #2716
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
KarloAldrete
wants to merge
3
commits into
PostHog:main
Choose a base branch
from
KarloAldrete:perf/streaming-markdown-blocks
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
64 changes: 64 additions & 0 deletions
64
packages/ui/src/features/editor/components/StreamingMarkdown.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Components>; | ||
| } | ||
|
|
||
| /** | ||
| * 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 ( | ||
| <div key={key}> | ||
| {before.trim() ? ( | ||
| <MarkdownRenderer | ||
| content={before} | ||
| componentsOverride={componentsOverride} | ||
| /> | ||
| ) : null} | ||
| <pre className="overflow-x-auto rounded-md border border-border bg-gray-3 p-2 text-[13px] leading-relaxed"> | ||
| <code>{code}</code> | ||
| </pre> | ||
| </div> | ||
| ); | ||
| } | ||
| return ( | ||
| <MarkdownRenderer | ||
| key={key} | ||
| content={block} | ||
| componentsOverride={componentsOverride} | ||
| /> | ||
| ); | ||
| })} | ||
| </> | ||
| ); | ||
| }); |
66 changes: 66 additions & 0 deletions
66
packages/ui/src/features/editor/components/splitMarkdownBlocks.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); | ||
| }); | ||
| }); |
107 changes: 107 additions & 0 deletions
107
packages/ui/src/features/editor/components/splitMarkdownBlocks.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }; | ||
| } | ||
69 changes: 69 additions & 0 deletions
69
packages/ui/src/features/editor/components/useSmoothedText.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<number, FrameRequestCallback>(); | ||
| 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); | ||
| }); | ||
| }); |
74 changes: 74 additions & 0 deletions
74
packages/ui/src/features/editor/components/useSmoothedText.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<number | null>(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); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hasOpenCodeFencere-implements the identical per-line regex match +inFence/fenceCharstate machine that lives insidesplitMarkdownBlocks. Extracting a shared helper (e.g.parseFenceState(lines)that returns{ inFence, fenceChar }) would satisfy the codebase's OnceAndOnlyOnce rule and make future spec-compliance fixes (e.g. requiring the closing fence to be at least as long as the opening fence) apply to both functions automatically.Prompt To Fix With AI
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!