🤖 fix: stabilize WorkspaceSidebarState getSnapshot #1226
Merged
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.
Fix React
useSyncExternalStorewarning by ensuringWorkspaceStore.getWorkspaceSidebarState()returns a referentially-stable snapshot within a givenWorkspaceStateversion (avoidsDate.now()-derived instability).Adds a regression test covering the active-stream + pending-tool case.
📋 Implementation Plan
Fix React
useSyncExternalStoregetSnapshot caching warning (Workspace sidebar)What’s happening / why this warns
React’s
useSyncExternalStore()callsgetSnapshot()more than once during a render/commit. IfgetSnapshot()returns a different value (byObject.is) between those reads, React warns:In our case the call chain is:
WorkspaceStatusIndicator→useWorkspaceSidebarState(workspaceId)useWorkspaceSidebarStateusesuseSyncExternalStore(..., () => store.getWorkspaceSidebarState(workspaceId))WorkspaceStore.getWorkspaceSidebarState()currently computestimingStatsusingStreamingMessageAggregator.getActiveStreamTimingStats(), which readsDate.now()(pending tool duration + live TPS).That makes consecutive
getSnapshot()reads produce different objects even when the store didn’t “change” (nobump()), tripping the warning and risking update loops.Recommended approach (minimal, correct): cache sidebar snapshot per workspace-state version
Net LoC (product code): +15–30
Goal
Make
WorkspaceStore.getWorkspaceSidebarState()return the exact same object reference whenever the underlying workspace state hasn’t changed (i.e., between consecutive React snapshot reads).Implementation steps
Add a per-workspace “source state” cache in
WorkspaceStore:sidebarStateCache: Map<string, WorkspaceSidebarState>already exists.sidebarStateSourceRef: Map<string, WorkspaceState>(or similar).Early-return the cached sidebar state when the underlying
WorkspaceStatereference is unchanged:getWorkspaceSidebarState(workspaceId):const fullState = this.getWorkspaceState(workspaceId);cachedSidebar && sidebarStateSourceRef.get(workspaceId) === fullState, returncachedSidebar.This ensures
getSnapshot()is deterministic for a given store version and avoids calling anyDate.now()-dependent code more than once per version.Keep the existing “field equality” logic for the cross-version case:
fullStatechanges becausestates.bump(workspaceId)happened, recompute the candidate sidebar state.Cleanup: ensure
removeWorkspace()deletes the newsidebarStateSourceRefentry.Verification
Regression test (recommended)
Net LoC (tests only; not counted in product LoC): +30–60
Add a focused test in
src/browser/stores/WorkspaceStore.test.ts:aggregator.handleStreamStart({ type: "stream-start", ... })aggregator.handleToolCallStart({ type: "tool-call-start", ... })store.states.bump()in between.)store.getWorkspaceSidebarState(workspaceId)twice.expect(first).toBe(second)This test specifically covers the prior failure mode where
Date.now()madetimingStatsdiffer across consecutive snapshot reads.Alternatives (if we want a larger cleanup)
A) Split “status UI” subscriptions from “timing stats” (strong long-term ergonomics)
Net LoC (product code): +40–90
useWorkspaceStatusIndicatorState(workspaceId)that returns only{ agentStatus, awaitingUserQuestion, canInterrupt, currentModel, recencyTimestamp }.useWorkspaceStatsSnapshot).Benefits: avoids coupling sidebar items to high-churn / time-derived fields.
B) Make timing stats updates explicit (if we truly want “live” TPS/tool time)
Net LoC (product code): +60–140
Date.now()usage from snapshot reads.useNow(250ms)),states.bump(workspaceId).Notes / follow-ups
useSyncExternalStorepitfall inuseAllExperiments()(ExperimentsContext.tsx) which returns a freshly-created object each snapshot read. It’s currently unused, but worth fixing if we ever start using it.usePersistedState.ts(it caches parsed JSON by raw localStorage string).Generated with
mux• Model:openai:gpt-5.2• Thinking:xhigh