From 94ae3801ffb9907dea9dc5e67444e3849ff5f0b2 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 1 May 2026 22:53:48 -0500 Subject: [PATCH 01/12] perf: per-block fade-in for streaming markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (//) 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. --- .../features/Messages/MarkdownCore.tsx | 4 +- .../Messages/TypewriterMarkdown.test.tsx | 24 +++++++++ .../features/Messages/TypewriterMarkdown.tsx | 7 ++- src/browser/styles/globals.css | 54 +++++++++++++++++++ 4 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/browser/features/Messages/MarkdownCore.tsx b/src/browser/features/Messages/MarkdownCore.tsx index 23eb94500f..2195a6b3ea 100644 --- a/src/browser/features/Messages/MarkdownCore.tsx +++ b/src/browser/features/Messages/MarkdownCore.tsx @@ -144,7 +144,9 @@ export const MarkdownCore = React.memo( // Use "static" mode for completed content to bypass useTransition() deferral. // After ORPC migration, async event boundaries let React deprioritize transitions indefinitely. mode={parseIncompleteMarkdown ? "streaming" : "static"} - className="space-y-2" // Reduce from default space-y-4 (16px) to space-y-2 (8px) + // streamdown-root: stable CSS hook for per-block fade-in (see globals.css). + // space-y-2: reduce from default space-y-4 (16px) to space-y-2 (8px). + className="streamdown-root space-y-2" controls={{ table: false, code: true, mermaid: true }} // Disable table copy/download, keep code/mermaid controls > {normalizedContent} diff --git a/src/browser/features/Messages/TypewriterMarkdown.test.tsx b/src/browser/features/Messages/TypewriterMarkdown.test.tsx index f71b3568d6..75427cb201 100644 --- a/src/browser/features/Messages/TypewriterMarkdown.test.tsx +++ b/src/browser/features/Messages/TypewriterMarkdown.test.tsx @@ -153,4 +153,28 @@ describe("TypewriterMarkdown", () => { expect(mockUseWorkspaceStreamingStats).toHaveBeenCalledWith("ws-active"); }); + + // The data-streaming attribute is the gate for the per-block fade-in CSS rule + // (see globals.css, .markdown-content[data-streaming="true"] .streamdown-root > *). + // It must only be present on the wrapper while the message is actively streaming + // — otherwise historical/replayed transcripts would re-trigger the animation + // every time their content prop changes. + test("sets data-streaming on the wrapper only while streaming", () => { + const streaming = render( + + ); + expect( + streaming.container.querySelector(".markdown-content")?.getAttribute("data-streaming") + ).toBe("true"); + streaming.unmount(); + + const completed = render( + + ); + // Absent (not "false") on completed messages so the CSS selector + // [data-streaming="true"] cannot match. + expect( + completed.container.querySelector(".markdown-content")?.getAttribute("data-streaming") + ).toBeNull(); + }); }); diff --git a/src/browser/features/Messages/TypewriterMarkdown.tsx b/src/browser/features/Messages/TypewriterMarkdown.tsx index 5de3712cb9..8f4153da6e 100644 --- a/src/browser/features/Messages/TypewriterMarkdown.tsx +++ b/src/browser/features/Messages/TypewriterMarkdown.tsx @@ -73,7 +73,12 @@ export const TypewriterMarkdown: React.FC = ({ return ( -
+ {/* + * data-streaming is the gate for per-block fade-in (see globals.css). The + * `|| undefined` keeps the attribute absent on completed messages so the + * CSS rule never matches them (and historical transcripts never animate). + */} +
//) are intentionally excluded because parseIncompleteMarkdown + * can transiently insert/remove them mid-stream as remend repairs unterminated + * tokens, which would over-fire the animation and look glitchy. + * - `> X` for top-level blocks; bare `li` so list items inside any (possibly + * nested)