Stop re-rendering the Workspace shell on every streaming delta#106
Merged
Conversation
Streaming text deltas arrive many times per second, and the accumulator
lived inside SessionState — so each appendDelta gave sessions[sessionId]
a new identity (immer structural sharing), re-rendering every
whole-session subscriber per token chunk. The worst offender was the
Workspace page (2,200+ lines of component body) which subscribed to the
entire session object.
Two changes, per external review feedback (validated against the code):
1. Move the accumulator to a top-level streamingText map in the SAME
store (sessionId -> messageId -> text). Deltas now invalidate only
streamingText subscribers, while completeMessage still swaps
accumulator-for-final-message atomically in one set() — no flicker
frame. appendDelta guards its isStreaming write so repeat deltas
leave the session draft untouched (pinned by a new identity-stability
test).
2. Narrow selectors in the Workspace shell: each rendered value gets its
own subscription; the per-delta streamingText subscription moves down
into ChatFirstLayout so token chunks re-render only the chat area.
WorkspaceTaskTranscriptPanel and useAssistantSession get the same
treatment. Stable EMPTY_* fallbacks keep memoized children from
seeing fresh []/{} identities.
A separate store was deliberately NOT used for the accumulator: it would
split completeMessage's clear+install across two updates and open a
one-frame flicker window.
juacker
marked this pull request as ready for review
July 11, 2026 21:43
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.
Problem
Streaming text deltas arrive many times per second, and the accumulator lived inside
SessionState— so eachappendDeltagavesessions[sessionId]a new identity (immer structural sharing), re-rendering every whole-session subscriber per token chunk. The worst offender: the Workspace page (2,200+ lines of component body) subscribed to the entire session object atWorkspace.tsx:1536. On WebKitGTK this is measurable render pressure (planet9 profiling: iowait there is GPU-fence accounting, i.e. compositor/frame pressure — exactly what per-token full-page re-renders feed).Based on external review feedback about the store's zustand usage — validated against the code before implementing.
Changes
1. Streaming accumulator moved to a top-level
streamingTextmap in the same store (sessionId → messageId → text).streamingTextsubscribers;sessions[sessionId]identity is untouched (pinned by a new identity-stability test).appendDeltaguards itsisStreamingwrite so repeat deltas leave the session draft unmodified.completeMessagestill swaps accumulator-for-final-message atomically in oneset()— a second store would open a one-frame flicker window between "streamed text gone" and "final message present".completeMessage,updateMessageContent,removeMessage,setRunStatus(terminal clears),removeSession(no leak),loadSessionData(snapshot refresh can no longer wipe in-flight text by construction).2. Narrow selectors in the page shell.
streamingTextsubscription moves down intoChatFirstLayout, so token chunks re-render only the chat area.WorkspaceTaskTranscriptPanelanduseAssistantSession.EMPTY_*fallback constants (outside selectors — selector results stay referentially stable foruseSyncExternalStore).Behavior
No user-visible behavior change intended: event ingestion is unaffected (selectors gate re-renders, not data), fallback semantics (
??vs||) preserved exactly,ChatMessageList's public prop API unchanged.Validation
sessionStoretests: 29 (5 new, incl. the perf contract: session identity unchanged across deltas),useAssistantEvents: 4 — all green.tsc --noEmitand eslint clean.production_quality, no findings.Draft for @juacker to test live streaming, run completion (no text flicker at the streamed→final swap), workspace switching, and the task transcript panel.