This document locks in the streaming hot-path architecture. Do not “simplify” these without measured evidence and an update to the regression tests.
| Invariant | Baseline | Why it exists |
|---|---|---|
| Streaming UI flush | ≤31 Hz (dirty-driven setTimeout 32 ms + rAF) |
Per-token set(map) caused 55 Hz reconciliations + full Markdown re-parse (8 ms) → jank. Buffer at ~30 Hz halves scripting/layout. |
| Per-token store updates | Prohibited | Each chunk appends to reply string (growing) and would trigger messages.map + parseMarkdown(full) → O(n²). |
Full App renders during stream |
0 | App previously broad useChatStore() re-rendered sidebar/model-selector/composer per token. Now ChatViewport isolates messages/input/isLoading via selectors; App reads only chats/selectedChatId. |
Completed MessageBubble renders |
0 | messages.slice() keeps completed refs stable, MessageBubble is memo, VirtualMessageList uses stable getItemKey via itemsRef. |
| Active Markdown parse | Bounded to tail ~900 chars | Full 10 k re-parse 7.8 ms → tail 0.7 ms. StreamingMarkdown (findStableCut + TAIL_CHARS) keeps stable prefix memo non-streaming, tail streaming. |
| Canonical final render | Full source, streaming:false |
Streaming may temporarily mis-format at split, but final set({messages: savedViewMessages}) after AddMessage renders TechnicalContent non-streaming. |
| Idle stream timers | 0 | createStreamScheduler holds at most 1 setTimeout + 1 rAF only while dirty; dispose() on completion/abort/chat-switch. Permanent setInterval(32) while idle is prohibited. |
| Conversation DOM | Virtualized | VirtualMessageList useVirtualizer mounts ~16 rows (visible + overscan 6) not 1000; key={selectedChatId} on ChatWorkspace isolates measurements per chat. |
| Per-word blur/filter | Prohibited | span.t-stream-w previously filter:blur(1px) + will-change on 2000 nodes → GPU overdraw. Now opacity only, MAX_ANIMATED_WORDS 90, LARGE_TEXT_THRESHOLD 1800, will-change removed. |
Mass will-change |
Prohibited | Large collections force layer promotion. Only single .trStream keeps will-change:transform (one element). |
| Heavy non-UI work | Off main thread / Go | Markdown tail is bounded; DB ListMessages/AddMessage via Wails, fetchAllModels debounced 300 ms + open gate. |
- ~30 Hz dirty scheduler (
frontend/src/lib/stream-scheduler.ts,frontend/src/stores/chat-store.ts) — LLM yields 20–60 chunks/s. Flushing per chunk → 55 React renders/s. Scheduler coalescespendingReply/pendingReasoningand flushes viasetTimeout(32)→rAF, finalflushSyncexact. Prevents stalesetIntervalwhile idle and guaranteesstable+tail===originalon final flush. - Bounded tail (
frontend/src/features/chat/components/technical-content.tsx:findStableCut) — Splits at last\n\n/\nbeforelength-900, fence-aware (```/~~~odd → 0).stablememostreaming:false,tailstreaming:true900 chars. Avoids full re-parse and huge animated trees; degrades to0(no split) if no safe boundary or inside fence. - Stable completed references (
frontend/src/stores/chat-store.ts:flushStreaming) —next = state.messages.slice(); next[idx]={...}keeps 199/200 refs stable →memoholds. - Virtualized conversation (
frontend/src/components/shared/virtual-message-list.tsx) —useVirtualizersingle instance,itemsRefstablegetKey,key={selectedChatId}onChatWorkspaceinChatViewportguaranteesResizeObserverandmeasureElementcache per chat, scroll not leaking. - Final canonical full render (
StreamingMarkdownvsTechnicalContent) — Streaming uses split, completed usesparseMarkdown(full, streaming:false);streamingMarkdownExtensiononly for tail.
- Restore per-token
set(state=>state.messages.map(...)). - Parse full
contenton every chunk. - Add
filter:blur,backdrop-blur, orwill-changeto hundreds of spans. - Remove
key={selectedChatId}or re-introducescrollModedead API. - Replace
findStableCutwith a full Markdown parser. - Re-introduce permanent
setIntervalwhile idle.
frontend/src/lib/stream-scheduler.test.ts— at most one timeout/rAF, coalesce, 30 Hz ceiling, final sync, abort, no idle.frontend/src/features/chat/components/technical-content.test.ts—stable+tail===originalfor\n,\n\n, Unicode, lists, blockquotes, fences, tables, links, etc.; fence-aware, offset correctness.frontend/src/lib/long-session.test.ts— 1000 msgs, 100 KB code blocks, 50 switches, abort/retry, delete, heap/DOM/timer bounds.frontend/src/stores/isolation.test.ts+frontend/src/stores/chat-store.test.ts—chatsselector stable duringmessagesstream,App0 renders, abort/chat-switch cannot mutate wrong chat.frontend/src/components/shared/virtual-message-list.test.tsx— mounted rows <30,keyremount isolates, noscrollMode.
Run npm --prefix frontend run test and npm --prefix frontend run build after any streaming/virtualizer/markdown change.