Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions packages/ui/src/features/editor/components/StreamingMarkdown.tsx
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}
/>
);
})}
</>
);
});
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 packages/ui/src/features/editor/components/splitMarkdownBlocks.ts
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 };
}
Comment on lines +71 to +107

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Fence-tracking logic duplicated between the two exported functions

hasOpenCodeFence re-implements the identical per-line regex match + inFence/fenceChar state machine that lives inside splitMarkdownBlocks. 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
This is a comment left during a code review.
Path: packages/ui/src/features/editor/components/splitMarkdownBlocks.ts
Line: 59-74

Comment:
**Fence-tracking logic duplicated between the two exported functions**

`hasOpenCodeFence` re-implements the identical per-line regex match + `inFence`/`fenceChar` state machine that lives inside `splitMarkdownBlocks`. 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.

How can I resolve this? If you propose a fix, please make it concise.

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!

69 changes: 69 additions & 0 deletions packages/ui/src/features/editor/components/useSmoothedText.test.ts
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 packages/ui/src/features/editor/components/useSmoothedText.ts
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);
}
Loading