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 thread
charlesvien marked this conversation as resolved.
97 changes: 97 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,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));
});
});
Loading
Loading