Skip to content

perf(desktop): subscribe components to the session fields they use - #169

Merged
GoDiao merged 1 commit into
mainfrom
perf/fine-grained-selectors
Oct 23, 2026
Merged

GoDiao merged 1 commit into
mainfrom
perf/fine-grained-selectors

Conversation

@GoDiao

@GoDiao GoDiao commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Summary

Components that used to subscribe to the whole session object (or the whole chat store) now subscribe only to the fields, or derived values, they render. Each tool group gets only its own results, with stable references, so finished groups stop re-rendering while a reply streams.

Part of #153. Thinking-delta batching (#155 / #160) is untouched.

What changed

Store (chatStore.ts) – two small hooks shared by several components:

  • useSessionField(sessionId, key) – one field of one session.
  • useSessionRuntimeSelection(sessionId) – the desired runtime via useShallow, so the reference survives mirror snapshots that don't change it.

Per component

  • ActiveSession – a boolean "is running" selector instead of chatState, a boolean for "any background task running", a boolean for "has messages or streaming text" (the only thing isEmpty needed), and activeGoal.
  • ChatInput – actions through their own selectors instead of useChatStore(); per-field chatState, slashCommands (stable empty fallback), composerPrefill, composerInsertion, permissionMode, the effective permission mode, the runtime selection, and messages.length (the only use of messages).
  • ModelSelector (child of the composer, same whole-session pattern) – uses useSessionRuntimeSelection.
  • StreamingIndicator – chatState, statusVerb, apiRetry, the turn's startedAt and output_tokens as primitives.
  • PermissionDialog – respondToPermission selector and a boolean "is my request pending".
  • AskUserQuestion – respondToPermission selector and the pending request id when it matches its toolUseId.
  • MessageList – per-field selectors, plus derived activeAskUserQuestionToolUseId and hasActiveTool. It still re-renders on every text flush because it renders streamingText; splitting that out is Render streaming replies incrementally instead of re-parsing the whole reply #154.

Tool results per group – buildToolGroupResults gives each tool group only its own tool calls, results, child calls and agent notifications, and reuses the previous group's arrays/maps when their entries are unchanged. ToolCallGroup was already memo, so finished groups now skip rendering. The derived toolResultByToolUseId map (new {content, isError} objects on every rebuild) is gone; MessageBlock gets the tool result message itself, which has a stable reference.

Measurement

How: a render-count harness in the tests (streamingRenderScenario.ts) plays one streaming reply as 31 store updates shaped like the real ones: a user message and thinking state, 20 text flushes, 5 token-usage updates, a tool call, a permission request and its resolution, the tool result, and the final message. Each component is rendered under its own React.Profiler, and the count is the number of commits for that subtree during the reply. ActiveSession is measured with its heavy children stubbed (its own test file). Tool groups are counted with a counting wrapper around ToolCallGroup. "Before" is the same harness run on main (6f07ea9) before any source change.

Component (31 store updates) Before After
ActiveSession 31 2
ChatInput 31 8
StreamingIndicator 32 14
MessageList 65 62
PermissionDialog 31 2
AskUserQuestion (no request for it) 31 0
Finished tool group from history 4 0
Live tool group 5 4

The remaining renders are the ones where a shown field changed: ChatInput on the 8 chatState changes, StreamingIndicator on status/verb/token changes, the live group on its appearance, isStreaming flips around the permission prompt, and its result. MessageList is #154.

Tests

  • streamingRenderCounts.test.tsx / ActiveSession.renderCounts.test.tsx – the harness above, with the after numbers asserted (MessageList pinned at 62, so a regression to a whole-store subscription fails at 65).
  • Still-updates tests: ChatInput on permissionMode, the effective mode, composerPrefill, slashCommands and chatState; StreamingIndicator on statusVerb, tokenUsage and apiRetry; PermissionDialog and AskUserQuestion on their pending request; ActiveSession on running state, background tasks, the goal and the first message; a tool group re-rendering with its own result while the history group does not; the runtime selection keeping its reference until the desired runtime changes.
  • buildToolGroupResults unit tests: per-group results, children and grandchildren, per-group agent notifications, and reused references for unchanged groups (a changed notification gets a new reference, an unchanged one keeps it).
  • cd desktop && bun run test – 117 files, 1209 passed. bun run lint (tsc --noEmit) – clean.

@GoDiao
GoDiao force-pushed the perf/fine-grained-selectors branch from 1752461 to 0cbde96 Compare October 23, 2026 14:27
ActiveSession, ChatInput, ModelSelector, StreamingIndicator, MessageList, PermissionDialog and AskUserQuestion select only the fields or derived values they render instead of the whole session object or store. Each tool group gets only its own results, children and agent notifications, with references kept while they are unchanged, so finished groups no longer re-render while a reply streams.

Part of #153
@GoDiao
GoDiao force-pushed the perf/fine-grained-selectors branch from 0cbde96 to d5b024e Compare October 23, 2026 14:29
@GoDiao
GoDiao merged commit 3467712 into main Oct 23, 2026
4 checks passed
@GoDiao
GoDiao deleted the perf/fine-grained-selectors branch October 23, 2026 14:34
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