Skip to content

Commit 94ae380

Browse files
committed
perf: per-block fade-in for streaming markdown
Adds a soft, GPU-cheap fade-in to each newly-mounted top-level markdown block while a message is streaming. Maps user's mental model of "line-by-line" onto markdown blocks: paragraph, heading, list item, fence, blockquote, table, hr — fires once per new block at mount. Mechanics - TypewriterMarkdown sets data-streaming="true" on its wrapper while the message is streaming. Absent (not "false") on completed/replayed messages so the CSS rule cannot match historical transcripts. - MarkdownCore appends "streamdown-root" to the className passed through to Streamdown's root div — a stable CSS hook with no extra wrapper. - globals.css adds a keyframes + block-level selector list scoped via prefers-reduced-motion. Inline elements (<strong>/<em>/<a>) are intentionally excluded because parseIncompleteMarkdown can transiently insert/remove them as remend repairs unterminated tokens. Why this works - Streamdown keys each block by useId+index and wraps it in React.memo, so existing blocks are reconciled in place when their content grows. Only newly-arriving top-level blocks mount fresh — and CSS animation on a freshly-mounted child fires exactly once at mount. - No JS bookkeeping, no render-phase work, no prop changes. Per-character pacing inside SmoothTextEngine still operates inside each block; the new fade-in layer is purely additive at block boundaries.
1 parent af4912e commit 94ae380

4 files changed

Lines changed: 87 additions & 2 deletions

File tree

src/browser/features/Messages/MarkdownCore.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,9 @@ export const MarkdownCore = React.memo<MarkdownCoreProps>(
144144
// Use "static" mode for completed content to bypass useTransition() deferral.
145145
// After ORPC migration, async event boundaries let React deprioritize transitions indefinitely.
146146
mode={parseIncompleteMarkdown ? "streaming" : "static"}
147-
className="space-y-2" // Reduce from default space-y-4 (16px) to space-y-2 (8px)
147+
// streamdown-root: stable CSS hook for per-block fade-in (see globals.css).
148+
// space-y-2: reduce from default space-y-4 (16px) to space-y-2 (8px).
149+
className="streamdown-root space-y-2"
148150
controls={{ table: false, code: true, mermaid: true }} // Disable table copy/download, keep code/mermaid controls
149151
>
150152
{normalizedContent}

src/browser/features/Messages/TypewriterMarkdown.test.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,28 @@ describe("TypewriterMarkdown", () => {
153153

154154
expect(mockUseWorkspaceStreamingStats).toHaveBeenCalledWith("ws-active");
155155
});
156+
157+
// The data-streaming attribute is the gate for the per-block fade-in CSS rule
158+
// (see globals.css, .markdown-content[data-streaming="true"] .streamdown-root > *).
159+
// It must only be present on the wrapper while the message is actively streaming
160+
// — otherwise historical/replayed transcripts would re-trigger the animation
161+
// every time their content prop changes.
162+
test("sets data-streaming on the wrapper only while streaming", () => {
163+
const streaming = render(
164+
<TypewriterMarkdown content="Live" isComplete={false} streamKey="msg-anim-live" />
165+
);
166+
expect(
167+
streaming.container.querySelector(".markdown-content")?.getAttribute("data-streaming")
168+
).toBe("true");
169+
streaming.unmount();
170+
171+
const completed = render(
172+
<TypewriterMarkdown content="Done" isComplete={true} streamKey="msg-anim-done" />
173+
);
174+
// Absent (not "false") on completed messages so the CSS selector
175+
// [data-streaming="true"] cannot match.
176+
expect(
177+
completed.container.querySelector(".markdown-content")?.getAttribute("data-streaming")
178+
).toBeNull();
179+
});
156180
});

src/browser/features/Messages/TypewriterMarkdown.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,12 @@ export const TypewriterMarkdown: React.FC<TypewriterMarkdownProps> = ({
7373

7474
return (
7575
<StreamingContext.Provider value={streamingContextValue}>
76-
<div className={cn("markdown-content", className)}>
76+
{/*
77+
* data-streaming is the gate for per-block fade-in (see globals.css). The
78+
* `|| undefined` keeps the attribute absent on completed messages so the
79+
* CSS rule never matches them (and historical transcripts never animate).
80+
*/}
81+
<div className={cn("markdown-content", className)} data-streaming={isStreaming || undefined}>
7782
<MarkdownCore
7883
content={visibleText}
7984
parseIncompleteMarkdown={isStreaming}

src/browser/styles/globals.css

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1542,6 +1542,60 @@ code {
15421542
white-space: normal;
15431543
}
15441544

1545+
/* Per-block fade-in for streaming content.
1546+
*
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.
1554+
*
1555+
* 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.
1560+
*
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 {
1570+
from {
1571+
opacity: 0;
1572+
transform: translateY(2px);
1573+
}
1574+
to {
1575+
opacity: 1;
1576+
transform: translateY(0);
1577+
}
1578+
}
1579+
1580+
@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;
1596+
}
1597+
}
1598+
15451599
.markdown-content h1,
15461600
.markdown-content h2,
15471601
.markdown-content h3,

0 commit comments

Comments
 (0)