diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts index 0762c0e1c9e..d302e77865e 100644 --- a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react' import { useAppStore } from '@/store' +import { useShallow } from 'zustand/react/shallow' import type { DashboardBucket } from '../../../../shared/dashboard-snapshot' import { buildDashboardSnapshot } from './build-dashboard-snapshot' @@ -13,19 +14,33 @@ const EMPTY_COUNTS: AgentBucketCounts = { attention: 0, working: 0, idle: 0 } * only when an input slice changes (mirrors useDashboardData's cost profile). */ export function useAgentBucketCounts(): AgentBucketCounts { - const repos = useAppStore((s) => s.repos) - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) - const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) - const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) - const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey) - const migrationUnsupportedByPtyId = useAppStore((s) => s.migrationUnsupportedByPtyId) - const runtimeAgentOrchestrationByPaneKey = useAppStore( - (s) => s.runtimeAgentOrchestrationByPaneKey + const { + repos, + worktreesByRepo, + tabsByWorktree, + agentStatusByPaneKey, + retainedAgentsByPaneKey, + migrationUnsupportedByPtyId, + runtimeAgentOrchestrationByPaneKey, + terminalLayoutsByTabId, + ptyIdsByTabId, + runtimePaneTitlesByTabId, + agentStatusEpoch + } = useAppStore( + useShallow((s) => ({ + repos: s.repos, + worktreesByRepo: s.worktreesByRepo, + tabsByWorktree: s.tabsByWorktree, + agentStatusByPaneKey: s.agentStatusByPaneKey, + retainedAgentsByPaneKey: s.retainedAgentsByPaneKey, + migrationUnsupportedByPtyId: s.migrationUnsupportedByPtyId, + runtimeAgentOrchestrationByPaneKey: s.runtimeAgentOrchestrationByPaneKey, + terminalLayoutsByTabId: s.terminalLayoutsByTabId, + ptyIdsByTabId: s.ptyIdsByTabId, + runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, + agentStatusEpoch: s.agentStatusEpoch + })) ) - const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) - const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) - const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId) - const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) return useMemo(() => { const snapshot = buildDashboardSnapshot( diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx index 201857cf823..6b388f95fbb 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx @@ -34,7 +34,31 @@ vi.mock('@/lib/activate-tab-and-focus-pane', () => ({ activateTabAndFocusPane: vi.fn() })) -import { useDashboardPopoutBridge } from './useDashboardPopoutBridge' +import { + dashboardSnapshotInputsChanged, + useDashboardPopoutBridge +} from './useDashboardPopoutBridge' +import type { DashboardSnapshotState } from './build-dashboard-snapshot' +import type { AppState } from '@/store/types' + +type DashboardSnapshotWatchState = DashboardSnapshotState & Pick + +function makeSnapshotWatchState(): DashboardSnapshotWatchState { + return { + repos: [], + worktreesByRepo: {}, + tabsByWorktree: {}, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + runtimePaneTitlesByTabId: {}, + acknowledgedAgentsByPaneKey: {}, + agentStatusEpoch: 0 + } +} function Harness({ enabled }: { enabled: boolean }): null { useDashboardPopoutBridge(enabled) @@ -80,6 +104,34 @@ describe('useDashboardPopoutBridge', () => { expect(mocks.subscribeStore).not.toHaveBeenCalled() }) + it('ignores unrelated store writes while retaining every snapshot input', () => { + const previousState = makeSnapshotWatchState() + expect(dashboardSnapshotInputsChanged({ ...previousState }, previousState)).toBe(false) + + const referenceInputs = [ + 'repos', + 'worktreesByRepo', + 'tabsByWorktree', + 'agentStatusByPaneKey', + 'retainedAgentsByPaneKey', + 'migrationUnsupportedByPtyId', + 'runtimeAgentOrchestrationByPaneKey', + 'terminalLayoutsByTabId', + 'ptyIdsByTabId', + 'runtimePaneTitlesByTabId', + 'acknowledgedAgentsByPaneKey' + ] as const + for (const key of referenceInputs) { + expect( + dashboardSnapshotInputsChanged({ ...previousState, [key]: {} }, previousState), + key + ).toBe(true) + } + expect( + dashboardSnapshotInputsChanged({ ...previousState, agentStatusEpoch: 1 }, previousState) + ).toBe(true) + }) + it('releases every dashboard listener when the experiment is disabled', async () => { await act(async () => root.render()) diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts index 5c7f602679a..3a8e8c2c04e 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts @@ -1,13 +1,36 @@ import { useEffect } from 'react' -import { useAppStore } from '@/store' +import { useAppStore, type AppState } from '@/store' import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' -import { buildDashboardSnapshot } from './build-dashboard-snapshot' +import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' // Why: cap snapshot rebuilds during bursts of agent-status pings. The board is a // glanceable surface, so ~4 updates/sec is plenty and keeps the cross-worktree // rebuild off the hot path. const PUBLISH_THROTTLE_MS = 250 +type DashboardSnapshotWatchState = DashboardSnapshotState & Pick + +export function dashboardSnapshotInputsChanged( + state: DashboardSnapshotWatchState, + previousState: DashboardSnapshotWatchState +): boolean { + return ( + state.repos !== previousState.repos || + state.worktreesByRepo !== previousState.worktreesByRepo || + state.tabsByWorktree !== previousState.tabsByWorktree || + state.agentStatusByPaneKey !== previousState.agentStatusByPaneKey || + state.retainedAgentsByPaneKey !== previousState.retainedAgentsByPaneKey || + state.migrationUnsupportedByPtyId !== previousState.migrationUnsupportedByPtyId || + state.runtimeAgentOrchestrationByPaneKey !== previousState.runtimeAgentOrchestrationByPaneKey || + state.terminalLayoutsByTabId !== previousState.terminalLayoutsByTabId || + state.ptyIdsByTabId !== previousState.ptyIdsByTabId || + state.runtimePaneTitlesByTabId !== previousState.runtimePaneTitlesByTabId || + state.acknowledgedAgentsByPaneKey !== previousState.acknowledgedAgentsByPaneKey || + // Why: freshness can change a bucket without replacing any backing map. + state.agentStatusEpoch !== previousState.agentStatusEpoch + ) +} + /** * Runs in the MAIN window (mount once in App). Two responsibilities: * 1. While the pop-out dashboard is open, derive the snapshot from the live @@ -88,7 +111,12 @@ export function useDashboardPopoutBridge(enabled: boolean): void { open = next if (open) { if (!unsubscribeStore) { - unsubscribeStore = useAppStore.subscribe(publishThrottled) + unsubscribeStore = useAppStore.subscribe((state, previousState) => { + // Why: unrelated high-frequency store writes must not rebuild a cross-worktree snapshot. + if (dashboardSnapshotInputsChanged(state, previousState)) { + publishThrottled() + } + }) } publishNow() } else {