perf(desktop): agent events and workspace switches stop re-rendering and refetching the whole UI

Merged
KI
8d ago
+224-45

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:

  1. 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 through useClearActivePaneAttention (twice) and useAutoAdoptChatSessions, and each event refetches the bindings with a new lastEventAt.
  2. Every workspace switch re-rendered every sidebar row, several times. Four values changed identity on each navigation even when their content had not.
  3. 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 WorkspaceAgentEffects component that renders null, with the same arguments. An event now re-renders that component and the status indicators, not the page.
  • useHostWorkspaces and SandboxAccessProvider: both built their context values from useQueries arrays, which are new on every render. Both now pass a module-level combine, which TanStack caches and structurally shares while the query results are unchanged. The host value is memoized, and sandbox targets has its own memo.
  • Sidebar active row: each row called useMatchRoute(), which subscribes to every navigation. It now uses useRouterState with 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: useWorkspaceConnectionRefresh now calls a new refreshQuery util. 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 on activeWorkspaceId, 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's onSelectionClick stay stable.

Measurements

Dev build over CDP, with React DevTools and session replay turned off. Production numbers should be lower on both sides.

BeforeAfter
Hook events at 5/s: script time per 10 s2.20 s0.72 s
Hook events at 5/s: main-thread busy per 10 s3.96 s2.02 s
Sidebar row renders over 4 switches35366
Median switch, 8 live sessions (2 runs)331–343 ms228–235 ms
Total stall per 30 s of switching (2 runs)7.5–7.6 s5.8–6.5 s
Worst stall while switching~500 ms~550 ms
20 mixed hook events, 4 tabs mounted: bindings invalidations72034
Same: cancelled fetches70417
Same: script time per 10 s (2 runs)0.89–0.95 s0.38–0.51 s
Same: main-thread busy per 10 s (2 runs)1.9–2.1 s1.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. agentCredentialsChangedWorkspaceId and usageRefusal are built the same way as before.

Testing

  • bun test on the sidebar, host-workspaces, SandboxAccessProvider, useAutoAdoptChatSessions, useClearActivePaneAttention, and hooks/host-service suites: 257 pass
  • New refreshQuery.test.ts (real QueryClient, 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 typecheck in apps/desktop
  • biome check on 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: WorkspaceAgentEffects runs 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 renders null; their events now re-render only that component and the status indicators.
  • useHostWorkspaces and SandboxAccessProvider pass stable module-level combine functions to useQueries and 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.
  • selectWorkspaceFromEvent reads 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 refreshQuery collapses 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.