Skip to content

Stop re-rendering the Workspace shell on every streaming delta#106

Merged
juacker merged 1 commit into
mainfrom
perf/streaming-text-store-slice
Jul 11, 2026
Merged

Stop re-rendering the Workspace shell on every streaming delta#106
juacker merged 1 commit into
mainfrom
perf/streaming-text-store-slice

Conversation

@juacker

@juacker juacker commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Problem

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: the Workspace page (2,200+ lines of component body) subscribed to the entire session object at Workspace.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 streamingText map in the same store (sessionId → messageId → text).

  • Deltas now invalidate only streamingText subscribers; sessions[sessionId] identity is untouched (pinned by a new identity-stability test).
  • appendDelta guards its isStreaming write so repeat deltas leave the session draft unmodified.
  • Deliberately not a separate store: completeMessage still swaps accumulator-for-final-message atomically in one set() — a second store would open a one-frame flicker window between "streamed text gone" and "final message present".
  • All touchpoints updated: 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.

  • Workspace subscribes per-field (messages, toolCalls, isStreaming, runStartedAt, queued ids, pagination flags, runs); the per-delta streamingText subscription moves down into ChatFirstLayout, so token chunks re-render only the chat area.
  • Same treatment for WorkspaceTaskTranscriptPanel and useAssistantSession.
  • Stable EMPTY_* fallback constants (outside selectors — selector results stay referentially stable for useSyncExternalStore).

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

  • sessionStore tests: 29 (5 new, incl. the perf contract: session identity unchanged across deltas), useAssistantEvents: 4 — all green.
  • Full suite matches main's baseline exactly; tsc --noEmit and eslint clean.
  • Independent full-scope review: 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.

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
juacker marked this pull request as ready for review July 11, 2026 21:43
@juacker
juacker merged commit 76402d0 into main Jul 11, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant