Skip to content

Commit d17f4ff

Browse files
committed
perf: per-word blur-fade-in for streaming markdown
Replace the per-block fade-in (which fired only once per paragraph at mount, then text grew silently inside) with a per-word blur-fade-in. Mechanism: - rehypeSplitWordsForFade walks the HAST tree and wraps each whitespace-bounded word in <span class="sd-word">. Skips inside <pre>/<code>/KaTeX/Mermaid subtrees so code, math, and diagrams stay structurally intact. - MarkdownCore swaps in this rehype plugin only while parseIncompleteMarkdown=true (i.e., during streaming) — completed/ static markdown still renders without word spans (no extra DOM weight). - globals.css replaces the per-block keyframes with sd-word-blur-in (filter: blur(6px) → blur(0), opacity 0 → 1, 500ms ease-out). Still gated on [data-streaming="true"] and prefers-reduced-motion. Why per-word - The user reported the prior per-block effect was invisible in thinking blocks (which are typically a single paragraph — block fade-in fires once at mount, then text grows silently inside the same <p>). Per-word also animates inside an already-mounted block: each new whitespace boundary mounts a fresh span and triggers a fade for that word and only that word. - Stable identity at every word position: as text grows from "Hel" → "Hello", react-markdown reconciles the same span in place; only fresh whitespace boundaries mount new spans. Completed words stay static while only new words fade in. Tests - New rehypeSplitWordsForFade.test.ts covers the core invariants: word/whitespace tokenization, partial trailing word, empty input, pre/code skip, KaTeX skip, recursion into inline (<strong>/<em>).
1 parent cc51e30 commit d17f4ff

4 files changed

Lines changed: 288 additions & 40 deletions

File tree

src/browser/features/Messages/MarkdownCore.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import "katex/dist/katex.min.css";
1212
import { normalizeMarkdown } from "./MarkdownStyles";
1313
import { markdownComponents } from "./MarkdownComponents";
1414
import { INTERNAL_INLINE_SKILL_HREF_PREFIX, remarkInlineSkillLinks } from "./inlineSkillMarkdown";
15+
import { rehypeSplitWordsForFade } from "./rehypeSplitWordsForFade";
1516

1617
interface MarkdownCoreProps {
1718
content: string;
@@ -102,7 +103,7 @@ const sanitizeSchema = {
102103
},
103104
};
104105

105-
const REHYPE_PLUGINS: Pluggable[] = [
106+
const REHYPE_PLUGINS_BASE: Pluggable[] = [
106107
rehypeRaw, // Parse HTML elements first
107108
[rehypeSanitize, sanitizeSchema], // Sanitize HTML to prevent XSS (strips dangerous elements/attributes)
108109
[
@@ -123,6 +124,12 @@ const REHYPE_PLUGINS: Pluggable[] = [
123124
[rehypeKatex, { errorColor: "var(--color-muted-foreground)" }], // Render math
124125
];
125126

127+
// Streaming variant: also splits each whitespace-bounded word into a
128+
// `<span class="sd-word">` so the per-word blur-fade-in CSS rule fires once per
129+
// new word at mount. Order matters: rehypeKatex must run BEFORE the splitter so
130+
// .katex subtrees are tagged and excluded.
131+
const REHYPE_PLUGINS_STREAMING: Pluggable[] = [...REHYPE_PLUGINS_BASE, rehypeSplitWordsForFade];
132+
126133
/**
127134
* Core markdown rendering component that handles all markdown processing.
128135
* This is the single source of truth for markdown configuration.
@@ -139,7 +146,7 @@ export const MarkdownCore = React.memo<MarkdownCoreProps>(
139146
<Streamdown
140147
components={markdownComponents}
141148
remarkPlugins={preserveLineBreaks ? REMARK_PLUGINS_WITH_BREAKS : REMARK_PLUGINS}
142-
rehypePlugins={REHYPE_PLUGINS}
149+
rehypePlugins={parseIncompleteMarkdown ? REHYPE_PLUGINS_STREAMING : REHYPE_PLUGINS_BASE}
143150
parseIncompleteMarkdown={parseIncompleteMarkdown}
144151
// Use "static" mode for completed content to bypass useTransition() deferral.
145152
// After ORPC migration, async event boundaries let React deprioritize transitions indefinitely.
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { describe, expect, test } from "bun:test";
2+
import type { Element, Root } from "hast";
3+
import { transformWordsForFade } from "./rehypeSplitWordsForFade";
4+
5+
// Drive the transform function directly on a synthetic HAST tree (bypassing
6+
// the unified pipeline). The plugin's transformer is a pure synchronous walk,
7+
// so this keeps the test hermetic and avoids spinning up rehype just to exercise
8+
// node walking + text splitting.
9+
function runPlugin(tree: Root): Root {
10+
transformWordsForFade(tree);
11+
return tree;
12+
}
13+
14+
function p(...children: Element["children"]): Element {
15+
return { type: "element", tagName: "p", properties: {}, children };
16+
}
17+
18+
function root(...children: Root["children"]): Root {
19+
return { type: "root", children };
20+
}
21+
22+
describe("rehypeSplitWordsForFade", () => {
23+
test("wraps each whitespace-bounded word in a sd-word span", () => {
24+
const tree = root(p({ type: "text", value: "Hello world foo" }));
25+
runPlugin(tree);
26+
const para = tree.children[0] as Element;
27+
expect(para.children).toHaveLength(5); // word, space, word, space, word
28+
const types = para.children.map((c) =>
29+
c.type === "element"
30+
? `<${c.tagName}.${(c.properties?.className as string[]).join(",")}>`
31+
: c.value
32+
);
33+
expect(types).toEqual(["<span.sd-word>", " ", "<span.sd-word>", " ", "<span.sd-word>"]);
34+
});
35+
36+
test("preserves whitespace runs as plain text (no spans)", () => {
37+
const tree = root(p({ type: "text", value: "a \n b" }));
38+
runPlugin(tree);
39+
const para = tree.children[0] as Element;
40+
// [span("a"), text(" \n "), span("b")]
41+
expect(para.children).toHaveLength(3);
42+
expect((para.children[0] as Element).tagName).toBe("span");
43+
expect(para.children[1].type).toBe("text");
44+
expect(para.children[2].type).toBe("element");
45+
});
46+
47+
test("partial trailing word (no whitespace yet) is still wrapped", () => {
48+
// Mid-stream: text node is "Hel" with nothing after it. Should still wrap
49+
// so React reconciles the same span as it grows to "Hello".
50+
const tree = root(p({ type: "text", value: "Hel" }));
51+
runPlugin(tree);
52+
const para = tree.children[0] as Element;
53+
expect(para.children).toHaveLength(1);
54+
expect((para.children[0] as Element).tagName).toBe("span");
55+
expect(((para.children[0] as Element).children[0] as { value: string }).value).toBe("Hel");
56+
});
57+
58+
test("empty text nodes produce no children", () => {
59+
const tree = root(p({ type: "text", value: "" }));
60+
runPlugin(tree);
61+
const para = tree.children[0] as Element;
62+
expect(para.children).toEqual([]);
63+
});
64+
65+
test("does not split inside <pre> / <code> subtrees", () => {
66+
// <pre><code>foo bar</code></pre> — must remain a single text node.
67+
const codeBlock: Element = {
68+
type: "element",
69+
tagName: "pre",
70+
properties: {},
71+
children: [
72+
{
73+
type: "element",
74+
tagName: "code",
75+
properties: {},
76+
children: [{ type: "text", value: "foo bar" }],
77+
},
78+
],
79+
};
80+
const tree = root(codeBlock);
81+
runPlugin(tree);
82+
const pre = tree.children[0] as Element;
83+
const code = pre.children[0] as Element;
84+
expect(code.children).toHaveLength(1);
85+
expect(code.children[0].type).toBe("text");
86+
expect((code.children[0] as { value: string }).value).toBe("foo bar");
87+
});
88+
89+
test("does not split inside KaTeX subtrees (class includes 'katex')", () => {
90+
// KaTeX renders as <span class="katex"> with complex inner spans containing
91+
// single-character text nodes. Splitting them would corrupt math layout.
92+
const katexNode: Element = {
93+
type: "element",
94+
tagName: "span",
95+
properties: { className: ["katex"] },
96+
children: [{ type: "text", value: "x = y + z" }],
97+
};
98+
const tree = root(p(katexNode));
99+
runPlugin(tree);
100+
const para = tree.children[0] as Element;
101+
const katex = para.children[0] as Element;
102+
// Inner text untouched.
103+
expect(katex.children).toHaveLength(1);
104+
expect(katex.children[0].type).toBe("text");
105+
expect((katex.children[0] as { value: string }).value).toBe("x = y + z");
106+
});
107+
108+
test("recurses into inline elements like <strong> and <em>", () => {
109+
// <p>hello <strong>brave new</strong> world</p>
110+
const strong: Element = {
111+
type: "element",
112+
tagName: "strong",
113+
properties: {},
114+
children: [{ type: "text", value: "brave new" }],
115+
};
116+
const tree = root(
117+
p({ type: "text", value: "hello " }, strong, { type: "text", value: " world" })
118+
);
119+
runPlugin(tree);
120+
const para = tree.children[0] as Element;
121+
122+
// Outer paragraph: split "hello " → [span("hello"), " "], then <strong> child
123+
// (its inner "brave new" should be split inside it), then split " world"
124+
// → [" ", span("world")].
125+
const outer = para.children;
126+
// Quick shape check: strong should be at some index, and its inner text
127+
// should be wrapped in word spans.
128+
const strongChild = outer.find(
129+
(c) => c.type === "element" && c.tagName === "strong"
130+
) as Element;
131+
expect(strongChild).toBeDefined();
132+
expect(strongChild.children).toHaveLength(3); // span("brave"), " ", span("new")
133+
expect((strongChild.children[0] as Element).tagName).toBe("span");
134+
expect((strongChild.children[2] as Element).tagName).toBe("span");
135+
});
136+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import type { Element, ElementContent, Root, RootContent, Text } from "hast";
2+
import type { Plugin } from "unified";
3+
4+
/**
5+
* Rehype plugin: wrap each whitespace-bounded word in a `<span class="sd-word">`
6+
* inside the rendered markdown HAST. When paired with the per-word blur-fade-in
7+
* CSS rule in `globals.css`, this gives a soft word-by-word reveal during
8+
* streaming — including inside an already-mounted paragraph or thinking block,
9+
* which is the case the per-block fade-in alone could not address.
10+
*
11+
* Stable identity: as text grows ("Hel" → "Hello"), the same `<span>` is
12+
* reconciled in place by react-markdown — no remount, no re-animation. Only
13+
* when a fresh whitespace boundary is crossed does a new `<span>` mount, which
14+
* is when the CSS animation fires. That's the behavior we want.
15+
*
16+
* Skip rules:
17+
* - Inside `<pre>` or `<code>` — code keeps its own monospaced/static feel.
18+
* - Inside KaTeX subtrees (class contains "katex") — splitting math breaks
19+
* glyph metrics. KaTeX output is generated by rehypeKatex and tagged with
20+
* that class, so this plugin must run AFTER rehypeKatex in the pipeline.
21+
* - Inside Mermaid `<svg>` (handled implicitly: SVG namespace nodes are
22+
* structural; we only split text *children* of HTML elements).
23+
* - Inside `<script>`/`<style>` — sanitize already strips these but we guard
24+
* defensively.
25+
*/
26+
27+
const SKIP_TAGS = new Set(["pre", "code", "script", "style"]);
28+
const SKIP_CLASS_SUBSTRINGS = ["katex", "mermaid"];
29+
30+
function hasSkipClass(node: Element): boolean {
31+
const cls = node.properties?.className;
32+
if (!Array.isArray(cls)) return false;
33+
for (const entry of cls) {
34+
const s = String(entry);
35+
for (const needle of SKIP_CLASS_SUBSTRINGS) {
36+
if (s.includes(needle)) return true;
37+
}
38+
}
39+
return false;
40+
}
41+
42+
/**
43+
* Split a text string into a sequence of HAST children:
44+
* "Hello world" → [<span>Hello</span>, " ", <span>world</span>]
45+
*
46+
* Whitespace runs stay as plain Text nodes so layout (line wrapping, justify,
47+
* etc.) is unchanged. Empty input is a no-op.
48+
*/
49+
function splitTextIntoWordSpans(value: string): ElementContent[] {
50+
if (value.length === 0) return [];
51+
const out: ElementContent[] = [];
52+
// Match non-whitespace runs OR whitespace runs. Together this tokenizes the
53+
// entire string with no gaps.
54+
const re = /(\S+)|(\s+)/g;
55+
let match: RegExpExecArray | null;
56+
while ((match = re.exec(value)) !== null) {
57+
const word = match[1];
58+
const ws = match[2];
59+
if (word !== undefined) {
60+
const span: Element = {
61+
type: "element",
62+
tagName: "span",
63+
properties: { className: ["sd-word"] },
64+
children: [{ type: "text", value: word } satisfies Text],
65+
};
66+
out.push(span);
67+
} else if (ws !== undefined) {
68+
out.push({ type: "text", value: ws });
69+
}
70+
}
71+
return out;
72+
}
73+
74+
function isElement(node: ElementContent | RootContent): node is Element {
75+
return node.type === "element";
76+
}
77+
78+
function isText(node: ElementContent | RootContent): node is Text {
79+
return node.type === "text";
80+
}
81+
82+
function transformChildren(parent: { children: ElementContent[] | RootContent[] }): void {
83+
// Re-allocate `children` so we can replace single text nodes with a sequence.
84+
const next: ElementContent[] = [];
85+
for (const child of parent.children as ElementContent[]) {
86+
if (isText(child)) {
87+
next.push(...splitTextIntoWordSpans(child.value));
88+
continue;
89+
}
90+
if (isElement(child)) {
91+
if (SKIP_TAGS.has(child.tagName) || hasSkipClass(child)) {
92+
next.push(child); // do not descend
93+
continue;
94+
}
95+
transformChildren(child);
96+
next.push(child);
97+
continue;
98+
}
99+
next.push(child);
100+
}
101+
parent.children = next;
102+
}
103+
104+
/**
105+
* Standalone transform function. Exported separately from the plugin wrapper
106+
* so unit tests can drive it directly without spinning up a unified processor.
107+
*/
108+
export function transformWordsForFade(tree: Root): void {
109+
transformChildren(tree);
110+
}
111+
112+
export const rehypeSplitWordsForFade: Plugin<[], Root> = () => {
113+
return (tree) => {
114+
transformWordsForFade(tree);
115+
};
116+
};

src/browser/styles/globals.css

Lines changed: 27 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1542,57 +1542,46 @@ code {
15421542
white-space: normal;
15431543
}
15441544

1545-
/* Per-block fade-in for streaming content.
1545+
/* Per-word blur-fade-in for streaming markdown content.
15461546
*
1547-
* Streamdown gives us per-block element identity for free: each top-level
1548-
* markdown block (paragraph, heading, list, fence, blockquote, table, hr) is
1549-
* keyed by its index inside Streamdown's render and reconciled in place when
1550-
* its content grows. So a CSS animation on a freshly-mounted child fires
1551-
* exactly once per new top-level block — never for text appended inside a
1552-
* still-open block. This gives users the "line-by-line fade-in" feel without
1553-
* any JS bookkeeping.
1547+
* Each whitespace-bounded word in a streaming message is wrapped in
1548+
* `<span class="sd-word">` by `rehypeSplitWordsForFade` (see MarkdownCore).
1549+
* On mount, each span animates from `filter: blur(6px); opacity: 0` to its
1550+
* final state — giving the soft per-word reveal users associate with modern
1551+
* AI chat transcripts (Grok, flowtoken, etc.).
1552+
*
1553+
* Why per-word, not per-block:
1554+
* - Per-block fired once per paragraph at mount, then text grew silently
1555+
* inside. Reasoning/thinking blocks are typically one big paragraph, so the
1556+
* effect was invisible there.
1557+
* - Per-word also animates inside an already-mounted paragraph: each new
1558+
* whitespace boundary mounts a fresh span → animation fires once for that
1559+
* word and only that word. Stable identity at every word position keeps
1560+
* completed words static while only new words fade in.
15541561
*
15551562
* Gating:
1556-
* - data-streaming="true" is set by TypewriterMarkdown only while isStreaming.
1557-
* Historical/replay messages render without the attribute and skip the rule.
1558-
* - prefers-reduced-motion: no-preference scopes the rule to users who haven't
1559-
* asked for reduced motion. Reduced-motion users see instant rendering.
1563+
* - `[data-streaming="true"]` is set by TypewriterMarkdown only on LIVE
1564+
* streams (not replay, not completed). Historical transcripts render
1565+
* without the attribute and skip the rule entirely.
1566+
* - `prefers-reduced-motion: no-preference` honors accessibility settings:
1567+
* users who request reduced motion see instant rendering.
15601568
*
1561-
* Selector scope:
1562-
* - Block-level descendants only — explicitly enumerated. Inline elements
1563-
* (<strong>/<em>/<a>) are intentionally excluded because parseIncompleteMarkdown
1564-
* can transiently insert/remove them mid-stream as remend repairs unterminated
1565-
* tokens, which would over-fire the animation and look glitchy.
1566-
* - `> X` for top-level blocks; bare `li` so list items inside any (possibly
1567-
* nested) <ul>/<ol> still animate as they arrive.
1568-
*/
1569-
@keyframes stream-block-fade-in {
1569+
* `<pre>`/`<code>`/KaTeX/Mermaid subtrees are excluded by the rehype plugin,
1570+
* so this rule never matches inside them. */
1571+
@keyframes sd-word-blur-in {
15701572
from {
1573+
filter: blur(6px);
15711574
opacity: 0;
1572-
transform: translateY(2px);
15731575
}
15741576
to {
1577+
filter: blur(0);
15751578
opacity: 1;
1576-
transform: translateY(0);
15771579
}
15781580
}
15791581

15801582
@media (prefers-reduced-motion: no-preference) {
1581-
.markdown-content[data-streaming="true"] .streamdown-root > p,
1582-
.markdown-content[data-streaming="true"] .streamdown-root > h1,
1583-
.markdown-content[data-streaming="true"] .streamdown-root > h2,
1584-
.markdown-content[data-streaming="true"] .streamdown-root > h3,
1585-
.markdown-content[data-streaming="true"] .streamdown-root > h4,
1586-
.markdown-content[data-streaming="true"] .streamdown-root > h5,
1587-
.markdown-content[data-streaming="true"] .streamdown-root > h6,
1588-
.markdown-content[data-streaming="true"] .streamdown-root > ul,
1589-
.markdown-content[data-streaming="true"] .streamdown-root > ol,
1590-
.markdown-content[data-streaming="true"] .streamdown-root > blockquote,
1591-
.markdown-content[data-streaming="true"] .streamdown-root > pre,
1592-
.markdown-content[data-streaming="true"] .streamdown-root > hr,
1593-
.markdown-content[data-streaming="true"] .streamdown-root > table,
1594-
.markdown-content[data-streaming="true"] .streamdown-root li {
1595-
animation: stream-block-fade-in 180ms ease-out;
1583+
.markdown-content[data-streaming="true"] .sd-word {
1584+
animation: sd-word-blur-in 500ms ease-out;
15961585
}
15971586
}
15981587

0 commit comments

Comments
 (0)