perf(desktop): agent events and workspace switches stop re-rendering and refetching the whole UI
Links
- Issue: #9136 (desktop freezes after 2.0.2). This PR does not close it: the reporter's freeze is not reproduced. Follows #9149.
Summary
- Agent hook events no longer re-render the whole open workspace page. Script time under steady hook events drops from 2.2 s to 0.7 s per 10 s.
- Workspace switches no longer re-render every sidebar row: 353 → 66 row renders over 4 switches. The median switch with live sessions goes from about 337 ms to about 231 ms.
- One agent event now causes one bindings refetch, not one per subscribing component: 720 → 34 invalidations for 20 events with a few tabs open.
Why / Context
A stress test for #9136 (fake Claude sessions sending real hook events through notify.sh, in up to 12 worktrees) found two places where React does much more work than the change needs:
- Every agent hook event re-rendered
V2WorkspaceContent, the top component of the open workspace, about 800 components per event. It read the workspace's agent bindings throughuseClearActivePaneAttention(twice) anduseAutoAdoptChatSessions, and each event refetches the bindings with a newlastEventAt. - Every workspace switch re-rendered every sidebar row, several times. Four values changed identity on each navigation even when their content had not.
- Every component reading the bindings refetched them itself. Each one subscribed to the agent events and ran its own cancel-then-invalidate, and each call cancelled the previous fetch. With a few tabs open, one hook event caused about 36 invalidations and cancelled requests.
How It Works
- Agent bindings: the three hook calls move into a new
WorkspaceAgentEffectscomponent that rendersnull, with the same arguments. An event now re-renders that component and the status indicators, not the page. useHostWorkspacesandSandboxAccessProvider: both built their context values fromuseQueriesarrays, which are new on every render. Both now pass a module-levelcombine, which TanStack caches and structurally shares while the query results are unchanged. The host value is memoized, and sandboxtargetshas its own memo.- Sidebar active row: each row called
useMatchRoute(), which subscribes to every navigation. It now usesuseRouterStatewith a selector that returns only that row's active flag, using the same rule: the path is/v2-workspace/<id>or below it. - Bindings refetch:
useWorkspaceConnectionRefreshnow calls a newrefreshQueryutil. Calls for the same query key in one task collapse into a single cancel-then-invalidate, run in a microtask. Every subscriber hears an event in the same task, so they share one refetch, and that refetch still starts after the event: a fetch in flight from before the event is cancelled and cannot settle. selectWorkspaceFromEvent: it depended onactiveWorkspaceId, which it only reads on click. It now reads the id from a ref that an effect updates, so the selection context and each row'sonSelectionClickstay stable.
Measurements
Dev build over CDP, with React DevTools and session replay turned off. Production numbers should be lower on both sides.
| Before | After | |
|---|---|---|
| Hook events at 5/s: script time per 10 s | 2.20 s | 0.72 s |
| Hook events at 5/s: main-thread busy per 10 s | 3.96 s | 2.02 s |
| Sidebar row renders over 4 switches | 353 | 66 |
| Median switch, 8 live sessions (2 runs) | 331–343 ms | 228–235 ms |
| Total stall per 30 s of switching (2 runs) | 7.5–7.6 s | 5.8–6.5 s |
| Worst stall while switching | ~500 ms | ~550 ms |
| 20 mixed hook events, 4 tabs mounted: bindings invalidations | 720 | 34 |
| Same: cancelled fetches | 704 | 17 |
| Same: script time per 10 s (2 runs) | 0.89–0.95 s | 0.38–0.51 s |
| Same: main-thread busy per 10 s (2 runs) | 1.9–2.1 s | 1.2–1.3 s |
Main-thread stalls were measured with a 16 ms heartbeat inside the renderer. A PerformanceObserver long-task count is not used: it reported 70 ms tasks that the heartbeat never saw.
Manual QA Checklist
- The sidebar highlight follows navigation: the new row lights up and the old one clears
- Shift-click range selection matches the old code (2 workspaces in both versions) and starts from the new active workspace after a switch
- A new terminal opens and runs in each of 8 workspaces after switching to it
- After each hook event, the cached bindings show the new event within about 150–250 ms of the hook being sent (prompt, stop, tool events)
- Steady tool events (status stays "working") cause no main-thread stalls beyond idle (≤75 ms on the heartbeat)
- Right-pane open and close on this branch. Not re-tested here; covered in #9149
- Cloud workspace open and wake. Not tested: no cloud workspaces in the dev profile.
agentCredentialsChangedWorkspaceIdandusageRefusalare built the same way as before.
Testing
bun teston the sidebar, host-workspaces,SandboxAccessProvider,useAutoAdoptChatSessions,useClearActivePaneAttention, andhooks/host-servicesuites: 257 pass- New
refreshQuery.test.ts(realQueryClient, no module mocks): calls in one task share one refetch, and a fetch in flight when the event lands does not settle the query. The first test fails against the old per-call behavior (13 reads instead of 2). bun run typecheckinapps/desktopbiome checkon the changed files
Known Limitations
- The worst switch stall did not improve. Mounting a workspace with many terminal tabs still costs about 0.5 s in the dev build. That is the next target.
- Earlier stress runs with heavier load (11 sessions and a 3,000-file change set) showed stalls of 1.1 s. The "before" run for this PR did not reproduce them, so this PR does not claim to fix them.
- No test guards render counts, so a later change could bring back an unstable context value without a test failing.
- Tab status indicators still re-render once per tab per event (about 0.5 ms per event in the dev build). A per-terminal subscription would remove that, but it changes a shared status hook for little gain.
Risks / Rollout
- Risk:
WorkspaceAgentEffectsruns its effects before the page's own effects, because children's effects run before their parent's. These hooks don't read state that the page's effects write. The auto-adopt and clear-attention tests pass. - Risk: a refetch now runs in a microtask after the event instead of synchronously in each subscriber, a delay well under a millisecond.
- Rollback: revert any of the three commits. Each fix is its own commit.
Summary by cubic
Speeds up the desktop UI by scoping re-renders to what actually changed: agent hook events no longer re-render the whole workspace page, and workspace switches no longer re-render every sidebar row. Script time under steady hook events drops from 2.2s to 0.7s per 10s, and median workspace switch with live sessions goes from about 337ms to about 231ms.
Refactors
- Agent binding hooks move from the workspace page into
WorkspaceAgentEffects, which rendersnull; their events now re-render only that component and the status indicators. useHostWorkspacesandSandboxAccessProviderpass stable module-levelcombinefunctions touseQueriesand memoize their context values.- Each sidebar row selects only its own active flag from the router state instead of subscribing to every navigation with
useMatchRoute; while a navigation loads it reads the resolved location, so a slow switch lights the destination row only when its page shows. selectWorkspaceFromEventreads the active workspace id from a ref updated by a layout effect, so the ref is fresh at click time and each row's click callback stays stable.- A new
refreshQuerycollapses bindings refetches for the same query key in one task into a single cancel-then-invalidate, run in a microtask.
Follows up on #9149 for #9136 but does not close it: the reported freeze was not reproduced. The worst switch stall did not improve, and mounting a workspace with many terminal tabs still takes about 0.5s in the dev build. WorkspaceAgentEffects runs its effects before the page's own effects, but the moved hooks don't read state those effects write.
Summary by CodeRabbit
- Bug Fixes
- The sidebar now highlights a workspace when its page or a nested page is open.
- Workspace selection remains consistent as the active workspace changes.
- Workspace panes clear attention indicators and can automatically adopt chat sessions when the layout is ready.
- Sandbox access is marked ready only after all access information has loaded.
- Improvements
- Workspace and sandbox information is presented more consistently while loading.
- Repeated workspace refresh requests are combined, reducing redundant updates.