Repository navigation
perf(desktop): render streaming replies incrementally - #171
Merged
Merged
Conversation
Split the streaming reply into finished Markdown blocks and a growing tail; finished blocks are parsed and sanitized once and stay mounted, so only the tail is redrawn on each flush and selections in the finished part survive. Code in the streaming reply uses Prism only; Shiki applies once the reply is committed. The streaming reply subscribes to the store itself, so MessageList no longer re-renders on text flushes, and following the bottom goes through one rAF-batched path. MarkdownRenderer's unused streaming cache is removed.
This was referenced Oct 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A streaming reply is no longer re-parsed, re-sanitized and replaced in full on every 50ms flush. Finished Markdown blocks are rendered once and stay mounted; only the block still being written is redrawn. MessageList stops re-rendering on text flushes, streaming code uses Prism only, and stick-to-bottom goes through one rAF-batched path.
Design
StreamingReply(new): subscribes tostreamingTextandchatStateitself. MessageList now subscribes only to "is there streaming text" (a boolean), so a flush re-renders the reply, not the transcript.splitStreamingBlocks(markdown/streamingBlocks.ts): lexes only the text after the last finished block withmarked.lexerand moves top-level tokens into an append-onlyfinishedlist. A block counts as finished only when the decision can't change: the next block's first line is complete, or it starts with a letter (so1. a\n\n2keeps the list open until2.is decided). Display math ($$ … $$,\[ … \]) that spans blank lines stays in one block; delimiters inside inline code don't count. Line endings are normalized to LF first so token lengths match the text. A reply with link reference definitions ([r]: url) falls back to whole-reply parsing, because marked doesn't keep definitions in tokenrawand blocks parsed alone can't see them. The split also stays conservative in two more cases, where the rest keeps rendering as one tail block (the pre-change behavior): from the first block-level HTML token onward (so a<details>and its body are never split), and whenever marked's tokenrawdoesn't concatenate back to the text exactly (marked inserts a newline in some paragraph + indented line +---inputs). If the text no longer starts with the finished prefix, it starts over.StreamingMarkdownRenderer: each finished block is a memoizedStreamingMarkdownBlock(parse + DOMPurify once). The tail is keyed byfinished.length, so when it finishes, the same instance becomes that block. Finished block DOM is never touched again, so a selection in it survives flushes. Parsing, KaTeX loading, part splitting and the click handler are shared withMarkdownRenderer(extracted helpers).MarkdownRenderer'sstreamingprop and its small streaming parse cache had no caller left, so they are removed; the cache for committed messages is unchanged.CodeViewergetshighlighter: 'shiki' | 'prism'. Streaming blocks passprism, which never loads Shiki, so the Prism → Shiki swap no longer flickers on every flush. When the turn ends, the reply moves intomessagesand renders throughMarkdownRenderer, which applies Shiki once.scheduleStickToBottomcoalesces to one write per animation frame. It is called from the content ResizeObserver and fromStreamingReplywhen the text grows. ThestreamingTextdependency is gone from the message-count effect. Virtual-viewport state is only updated while virtualization is on, so programmatic scrolls in short transcripts don't re-render MessageList.Timing (replaces the Safari Web Inspector timeline)
The maintainer approved this in place of the Safari timeline in the Done-when. Temporary dev-build instrumentation, removed before committing (see the end of this section).
Method:
tauri dev(WKWebView / JavaScriptCore, macOS). A bench entry mountedMessageListwith the app CSS and a fake session, then streamed a deterministic Markdown reply (headings, paragraphs with inline math, lists, tables,tscode fences, links), 80 characters per flush, one flush every 50ms. Per flush it measuredflushSync(store update)+ forced layout (document.body.offsetHeight), so the number covers React render + commit + effects + style/layout. Two runs per size, same harness and machine for both sides. On main, the scroll effect forces layout inside the commit. On this branch, the stick-to-bottom write happens in the next frame and isn't counted; it is onescrollTopwrite on an already laid-out tree.(run 1 / run 2; WebKit clamps
performance.now()to 1ms.)prism), 65 KaTeX renders, no raw$.The timing code (
src/__bench.tsx, amain.tsxhook, a vite middleware) isn't in this diff:git diff main | grep -E "__bench|VITE_STREAM_BENCH|bench-sink"is empty.Tests
streamingBlocks.test.ts: finished blocks are append-only at 1/3/17-char steps; a reply streamed at 1/5/40-char steps renders the same HTML as the whole reply; lists stay open until the next block is decided; display math with blank lines stays together and$$in inline code is ignored; CRLF text splits at the right offsets; replies with link reference definitions fall back to whole-reply parsing; block HTML and everything after it stays in the tail; a token-text mismatch keeps the remainder in the tail; unclosed fences stay in the tail; replaced text starts over.StreamingMarkdownRenderer.test.tsx: half-written list, table, code fence (rendered withprism) and math render correctly while streaming; finished blocks stay the same DOM nodes andmarked.parseonly sees the tail; a finished code block'sCodeVieweris not re-rendered when the tail grows; reference-style links render while streaming; a streaming<details>keeps its body inside it; a selection in the finished part stays selected across flushes.streamingRenderCounts.test.tsx: MessageList subtree commits during the scenario reply drop 62 → 44. The remaining one per text flush isStreamingReplyitself, which sits inside the profiled subtree.AssistantMessage.test.tsxandMarkdownRenderer.test.tsx: a streaming reply renders incrementally withprism; a committed reply renders in full and its code blocks getshiki. The test for the removed streaming cache is deleted.StreamingReply.test.tsx:StreamingReplyrenders the reply incrementally withprism; a committed reply inMessageListrenders in full withshiki.CodeViewer.test.tsx: with Shiki available,highlighter="prism"never loads it and the default switches to Shiki.MessageList.test.tsx: scroll tests now wait one frame before asserting, because following the bottom is rAF-batched.bun run lint(tsc) andbun run test -- --runindesktop/: 121 files, 1237 passed, 4 skipped. Rootbun test: 1337 pass, 10 fail; the same 10 fail onmainon this machine (agent/skill inheritance, ripgrep fallback, slash-command merge), and this PR changes nothing outsidedesktop/except the CHANGELOG. Each new regression test was checked to fail with its fix or guard removed (memo on finished blocks, the prism guard, the reference-definition fallback, CRLF normalization, inline-code skipping, Shiki for committed replies,AssistantMessagechoosing the renderer, the HTML stop, the token-text check, and both call sites passing or omittingincremental).Not covered
content-visibilityfor off-screen rows (from the issue's Direction, not its Done-when).Closes #154