diff --git a/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx index 508893c9fdb..8b89175d6fc 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx @@ -20,38 +20,43 @@ const sleepMocks = vi.hoisted(() => ({ let worktreeCardProperties: WorktreeCardProperty[] = ['status', 'comment'] let settings: Partial | null = null -vi.mock('@/store', () => ({ - useAppStore: (selector: (state: unknown) => unknown) => - selector({ - browserTabsByWorktree: {}, - createBrowserTab: vi.fn(), - deleteFolderWorkspace: vi.fn(), - deleteStateByWorktreeId: {}, - fetchHostedReviewForBranch: vi.fn(), - fetchIssue: vi.fn(), - fetchLinearIssue: vi.fn(), - gitConflictOperationByWorktree: {}, - hostedReviewCache: {}, - issueCache: {}, - linearIssueCache: {}, - openModal, - openTaskPage: vi.fn(), - projectGroups: [], - ptyIdsByTabId: {}, - remoteBranchConflictByWorktreeId: {}, - renamingWorktreeId: null, - setActiveWorktree: vi.fn(), - setRemoteBrowserPageHandle: vi.fn(), - setRenamingWorktreeId, - settings, - sshConnectionStates: new Map(), - sshTargetLabels: new Map(), - tabsByWorktree: {}, - updateWorktreeMeta, - workspacePortScan: null, - worktreeCardProperties +vi.mock('@/store', () => { + const getState = () => ({ + browserTabsByWorktree: {}, + createBrowserTab: vi.fn(), + deleteFolderWorkspace: vi.fn(), + deleteStateByWorktreeId: {}, + fetchHostedReviewForBranch: vi.fn(), + fetchIssue: vi.fn(), + fetchLinearIssue: vi.fn(), + gitConflictOperationByWorktree: {}, + hostedReviewCache: {}, + issueCache: {}, + linearIssueCache: {}, + openModal, + openTaskPage: vi.fn(), + projectGroups: [], + ptyIdsByTabId: {}, + remoteBranchConflictByWorktreeId: {}, + renamingWorktreeId: null, + setActiveWorktree: vi.fn(), + setRemoteBrowserPageHandle: vi.fn(), + setRenamingWorktreeId, + settings, + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + tabsByWorktree: {}, + updateWorktreeMeta, + workspacePortScan: null, + worktreeCardProperties + }) + return { + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), { + getState, + subscribe: () => () => {} }) -})) + } +}) vi.mock('@/components/ui/hover-card', () => ({ HoverCard: ({ children }: { children: ReactNode }) => <>{children}, diff --git a/src/renderer/src/components/sidebar/WorktreeCard.delete-failed.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.delete-failed.test.tsx index 11ef17ebe96..c4eb46a4ce1 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.delete-failed.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.delete-failed.test.tsx @@ -23,33 +23,38 @@ let worktreesByRepo: Record = {} let worktreeCardProperties: WorktreeCardProperty[] = ['status'] let deleteStateByWorktreeId: Record = {} -vi.mock('@/store', () => ({ - useAppStore: (selector: (state: unknown) => unknown) => - selector({ - deleteStateByWorktreeId, - fetchHostedReviewForBranch, - fetchIssue, - fetchLinearIssue, - gitConflictOperationByWorktree: {}, - hostedReviewCache: {}, - issueCache: {}, - linearIssueCache: {}, - openModal, - projectGroups: [], - remoteBranchConflictByWorktreeId: {}, - runtimeEnvironments, - runtimeStatusByEnvironmentId, - removedSshTargetLabels, - settings: null, - sshConnectionStates, - sshStateByEnvironment, - sshTargetLabels, - sshTargetsHydrated: true, - updateWorktreeMeta, - worktreesByRepo, - worktreeCardProperties +vi.mock('@/store', () => { + const getState = () => ({ + deleteStateByWorktreeId, + fetchHostedReviewForBranch, + fetchIssue, + fetchLinearIssue, + gitConflictOperationByWorktree: {}, + hostedReviewCache: {}, + issueCache: {}, + linearIssueCache: {}, + openModal, + projectGroups: [], + remoteBranchConflictByWorktreeId: {}, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + removedSshTargetLabels, + settings: null, + sshConnectionStates, + sshStateByEnvironment, + sshTargetLabels, + sshTargetsHydrated: true, + updateWorktreeMeta, + worktreesByRepo, + worktreeCardProperties + }) + return { + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), { + getState, + subscribe: () => () => {} }) -})) + } +}) vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() diff --git a/src/renderer/src/components/sidebar/WorktreeCard.host-badge-toggle.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.host-badge-toggle.test.tsx index 8c60f7a5f30..0e65f4f1e19 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.host-badge-toggle.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.host-badge-toggle.test.tsx @@ -20,28 +20,33 @@ let worktreeCardProperties: WorktreeCardProperty[] = [] let settings: Partial | null = null const WORKTREE_CARD_IMPORT_TIMEOUT_MS = 15_000 -vi.mock('@/store', () => ({ - useAppStore: (selector: (state: unknown) => unknown) => - selector({ - deleteStateByWorktreeId: {}, - fetchHostedReviewForBranch, - fetchIssue, - fetchLinearIssue, - gitConflictOperationByWorktree: {}, - hostedReviewCache: {}, - issueCache: {}, - linearIssueCache: {}, - openModal, - projectGroups: [], - remoteBranchConflictByWorktreeId: {}, - settings, - sshConnectionStates: new Map(), - sshTargetLabels: new Map(), - updateWorktreeMeta, - workspacePortScan: null, - worktreeCardProperties +vi.mock('@/store', () => { + const getState = () => ({ + deleteStateByWorktreeId: {}, + fetchHostedReviewForBranch, + fetchIssue, + fetchLinearIssue, + gitConflictOperationByWorktree: {}, + hostedReviewCache: {}, + issueCache: {}, + linearIssueCache: {}, + openModal, + projectGroups: [], + remoteBranchConflictByWorktreeId: {}, + settings, + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + updateWorktreeMeta, + workspacePortScan: null, + worktreeCardProperties + }) + return { + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), { + getState, + subscribe: () => () => {} }) -})) + } +}) vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() diff --git a/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx index 1add6d7a97a..f17a2a52e0c 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx @@ -17,34 +17,39 @@ let worktreeCardProperties: WorktreeCardProperty[] = ['status'] let root: Root | null = null let container: HTMLDivElement | null = null -vi.mock('@/store', () => ({ - useAppStore: (selector: (state: unknown) => unknown) => - selector({ - deleteStateByWorktreeId: {}, - fetchHostedReviewForBranch, - fetchIssue, - fetchLinearIssue, - gitConflictOperationByWorktree: {}, - hostedReviewCache: { - 'local::repo-1::feature/branch': { - data: null, - fetchedAt: Date.now(), - linkedReviewHintKey: '' - } - }, - issueCache: {}, - linearIssueCache: {}, - openModal, - projectGroups: [], - remoteBranchConflictByWorktreeId: {}, - settings: { experimentalNewWorktreeCardStyle: true }, - sshConnectionStates: new Map(), - sshTargetLabels: new Map(), - updateWorktreeMeta, - workspacePortScan: null, - worktreeCardProperties +vi.mock('@/store', () => { + const getState = () => ({ + deleteStateByWorktreeId: {}, + fetchHostedReviewForBranch, + fetchIssue, + fetchLinearIssue, + gitConflictOperationByWorktree: {}, + hostedReviewCache: { + 'local::repo-1::feature/branch': { + data: null, + fetchedAt: Date.now(), + linkedReviewHintKey: '' + } + }, + issueCache: {}, + linearIssueCache: {}, + openModal, + projectGroups: [], + remoteBranchConflictByWorktreeId: {}, + settings: { experimentalNewWorktreeCardStyle: true }, + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + updateWorktreeMeta, + workspacePortScan: null, + worktreeCardProperties + }) + return { + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), { + getState, + subscribe: () => () => {} }) -})) + } +}) vi.mock('@/lib/sidebar-worktree-activation', () => ({ activateWorktreeFromSidebar: vi.fn() diff --git a/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx index d7ccd755c68..83230bf880f 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx @@ -14,27 +14,32 @@ const updateWorktreeMeta = vi.fn() let worktreeCardProperties: WorktreeCardProperty[] = [] const WORKTREE_CARD_IMPORT_TIMEOUT_MS = 15_000 -vi.mock('@/store', () => ({ - useAppStore: (selector: (state: unknown) => unknown) => - selector({ - deleteStateByWorktreeId: {}, - fetchHostedReviewForBranch, - fetchIssue, - fetchLinearIssue, - gitConflictOperationByWorktree: {}, - hostedReviewCache: {}, - issueCache: {}, - linearIssueCache: {}, - openModal, - projectGroups: [], - remoteBranchConflictByWorktreeId: {}, - settings: null, - sshConnectionStates: new Map(), - sshTargetLabels: new Map(), - updateWorktreeMeta, - worktreeCardProperties +vi.mock('@/store', () => { + const getState = () => ({ + deleteStateByWorktreeId: {}, + fetchHostedReviewForBranch, + fetchIssue, + fetchLinearIssue, + gitConflictOperationByWorktree: {}, + hostedReviewCache: {}, + issueCache: {}, + linearIssueCache: {}, + openModal, + projectGroups: [], + remoteBranchConflictByWorktreeId: {}, + settings: null, + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + updateWorktreeMeta, + worktreeCardProperties + }) + return { + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), { + getState, + subscribe: () => () => {} }) -})) + } +}) vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() })) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx index e44647b3160..b5d65858ba4 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx @@ -19,29 +19,34 @@ let hostedReviewCache: Record = {} let prCache: Record = {} let settings: Partial | null = null -vi.mock('@/store', () => ({ - useAppStore: (selector: (state: unknown) => unknown) => - selector({ - deleteStateByWorktreeId: {}, - fetchHostedReviewForBranch, - fetchIssue, - fetchLinearIssue, - gitConflictOperationByWorktree: {}, - hostedReviewCache, - issueCache: {}, - linearIssueCache: {}, - openModal, - prCache, - projectGroups: [], - remoteBranchConflictByWorktreeId: {}, - settings, - sshConnectionStates: new Map(), - sshTargetLabels: new Map(), - updateWorktreeMeta, - workspacePortScan: null, - worktreeCardProperties +vi.mock('@/store', () => { + const getState = () => ({ + deleteStateByWorktreeId: {}, + fetchHostedReviewForBranch, + fetchIssue, + fetchLinearIssue, + gitConflictOperationByWorktree: {}, + hostedReviewCache, + issueCache: {}, + linearIssueCache: {}, + openModal, + prCache, + projectGroups: [], + remoteBranchConflictByWorktreeId: {}, + settings, + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + updateWorktreeMeta, + workspacePortScan: null, + worktreeCardProperties + }) + return { + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), { + getState, + subscribe: () => () => {} }) -})) + } +}) vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx index 8ac13a01093..f8bbf0064d0 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx @@ -16,28 +16,33 @@ let worktreeCardProperties: WorktreeCardProperty[] = [] let settings: Partial | null = null const WORKTREE_CARD_IMPORT_TIMEOUT_MS = 15_000 -vi.mock('@/store', () => ({ - useAppStore: (selector: (state: unknown) => unknown) => - selector({ - deleteStateByWorktreeId: {}, - fetchHostedReviewForBranch, - fetchIssue, - fetchLinearIssue, - gitConflictOperationByWorktree: {}, - hostedReviewCache: {}, - issueCache: {}, - linearIssueCache: {}, - openModal, - projectGroups: [], - remoteBranchConflictByWorktreeId: {}, - settings, - sshConnectionStates: new Map(), - sshTargetLabels: new Map(), - updateWorktreeMeta, - workspacePortScan: null, - worktreeCardProperties +vi.mock('@/store', () => { + const getState = () => ({ + deleteStateByWorktreeId: {}, + fetchHostedReviewForBranch, + fetchIssue, + fetchLinearIssue, + gitConflictOperationByWorktree: {}, + hostedReviewCache: {}, + issueCache: {}, + linearIssueCache: {}, + openModal, + projectGroups: [], + remoteBranchConflictByWorktreeId: {}, + settings, + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + updateWorktreeMeta, + workspacePortScan: null, + worktreeCardProperties + }) + return { + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), { + getState, + subscribe: () => () => {} }) -})) + } +}) vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() diff --git a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx index 8b5fe68de7b..2404d844e11 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx @@ -24,28 +24,33 @@ let workspaceDeleteModifierPressed = false let gitConflictOperationByWorktree: Record = {} let WorktreeCard: typeof WorktreeCardComponent -vi.mock('@/store', () => ({ - useAppStore: (selector: (state: unknown) => unknown) => - selector({ - deleteStateByWorktreeId: {}, - fetchHostedReviewForBranch, - fetchIssue, - gitConflictOperationByWorktree, - hostedReviewCache: {}, - issueCache: {}, - openModal, - projectGroups, - remoteBranchConflictByWorktreeId: {}, - settings, - sshConnectionStates: new Map(), - sshTargetLabels: new Map(), - browserTabsByWorktree, - ptyIdsByTabId, - tabsByWorktree, - updateWorktreeMeta, - worktreeCardProperties +vi.mock('@/store', () => { + const getState = () => ({ + deleteStateByWorktreeId: {}, + fetchHostedReviewForBranch, + fetchIssue, + gitConflictOperationByWorktree, + hostedReviewCache: {}, + issueCache: {}, + openModal, + projectGroups, + remoteBranchConflictByWorktreeId: {}, + settings, + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + browserTabsByWorktree, + ptyIdsByTabId, + tabsByWorktree, + updateWorktreeMeta, + worktreeCardProperties + }) + return { + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), { + getState, + subscribe: () => () => {} }) -})) + } +}) vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() diff --git a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx index 971aa751269..2b32298de11 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx @@ -22,33 +22,38 @@ let sshStateByEnvironment = new Map() let worktreesByRepo: Record = {} let worktreeCardProperties: WorktreeCardProperty[] = ['status'] -vi.mock('@/store', () => ({ - useAppStore: (selector: (state: unknown) => unknown) => - selector({ - deleteStateByWorktreeId: {}, - fetchHostedReviewForBranch, - fetchIssue, - fetchLinearIssue, - gitConflictOperationByWorktree: {}, - hostedReviewCache: {}, - issueCache: {}, - linearIssueCache: {}, - openModal, - projectGroups: [], - remoteBranchConflictByWorktreeId: {}, - runtimeEnvironments, - runtimeStatusByEnvironmentId, - removedSshTargetLabels, - settings: null, - sshConnectionStates, - sshStateByEnvironment, - sshTargetLabels, - sshTargetsHydrated: true, - updateWorktreeMeta, - worktreesByRepo, - worktreeCardProperties +vi.mock('@/store', () => { + const getState = () => ({ + deleteStateByWorktreeId: {}, + fetchHostedReviewForBranch, + fetchIssue, + fetchLinearIssue, + gitConflictOperationByWorktree: {}, + hostedReviewCache: {}, + issueCache: {}, + linearIssueCache: {}, + openModal, + projectGroups: [], + remoteBranchConflictByWorktreeId: {}, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + removedSshTargetLabels, + settings: null, + sshConnectionStates, + sshStateByEnvironment, + sshTargetLabels, + sshTargetsHydrated: true, + updateWorktreeMeta, + worktreesByRepo, + worktreeCardProperties + }) + return { + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), { + getState, + subscribe: () => () => {} }) -})) + } +}) vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx index 096a2856998..68165d560b3 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx @@ -47,16 +47,15 @@ type WorktreeListComponent = React.ComponentType<{ let WorktreeList: WorktreeListComponent -vi.mock('@/store', () => { - const useAppStore = ((selector: (state: Record) => unknown) => - selector(mockStore.state)) as (( - selector: (state: Record) => unknown - ) => unknown) & { - getState: () => Record - } - useAppStore.getState = () => mockStore.state - return { useAppStore } -}) +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: Record) => unknown) => selector(mockStore.state), + { + getState: () => mockStore.state, + subscribe: () => () => {} + } + ) +})) // Why: honor the real getItemKey so each virtual row's React key equals // getRenderRowKey(row). This is what makes the parent card remount when it diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx index 0fc8273d66a..70637ccfb3f 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx @@ -51,16 +51,15 @@ function makeFolderWorkspacePathStatusMockState(): Record { } } -vi.mock('@/store', () => { - const useAppStore = ((selector: (state: Record) => unknown) => - selector(mockStore.state)) as (( - selector: (state: Record) => unknown - ) => unknown) & { - getState: () => Record - } - useAppStore.getState = () => mockStore.state - return { useAppStore } -}) +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: Record) => unknown) => selector(mockStore.state), + { + getState: () => mockStore.state, + subscribe: () => () => {} + } + ) +})) vi.mock('@tanstack/react-virtual', () => ({ defaultRangeExtractor: ({ startIndex, endIndex }: { startIndex: number; endIndex: number }) => diff --git a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx index 2bcf76136d6..d091d8213de 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx @@ -52,16 +52,15 @@ function makeFolderWorkspacePathStatusMockState(): Record { } } -vi.mock('@/store', () => { - const useAppStore = ((selector: (state: Record) => unknown) => - selector(mockStore.state)) as (( - selector: (state: Record) => unknown - ) => unknown) & { - getState: () => Record - } - useAppStore.getState = () => mockStore.state - return { useAppStore } -}) +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: Record) => unknown) => selector(mockStore.state), + { + getState: () => mockStore.state, + subscribe: () => () => {} + } + ) +})) vi.mock('@tanstack/react-virtual', () => ({ defaultRangeExtractor: ({ startIndex, endIndex }: { startIndex: number; endIndex: number }) => diff --git a/src/renderer/src/components/sidebar/use-worktree-agent-rows-subscriptions.test.tsx b/src/renderer/src/components/sidebar/use-worktree-agent-rows-subscriptions.test.tsx new file mode 100644 index 00000000000..3b502328587 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-worktree-agent-rows-subscriptions.test.tsx @@ -0,0 +1,222 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { readStoreListenerCount } from '@/store/store-listener-census' +import { makeTab } from '@/store/slices/store-test-helpers' +import type { AppState } from '@/store/types' +import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { useWorktreeAgentRows } from './useWorktreeAgentRows' + +Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true) + +const LEAF = '11111111-1111-4111-8111-111111111111' +const KEY_A = makePaneKey('tab-a', LEAF) +const KEY_B = makePaneKey('tab-b', LEAF) +const original = useAppStore.getState() +let root: Root +let container: HTMLDivElement +let rows: DashboardAgentRow[] +let renders: number + +function Probe({ + worktreeId = 'folder-a', + active = true +}: { + worktreeId?: string + active?: boolean +}) { + rows = useWorktreeAgentRows(worktreeId, active) + renders += 1 + return null +} +function entry(key: string, worktreeId: string): AgentStatusEntry { + return { + paneKey: key, + worktreeId, + state: 'working', + agentType: 'claude', + prompt: worktreeId, + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [] + } +} +function patch(update: Partial) { + act(() => useAppStore.setState(update)) +} + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(2_000_000) + useAppStore.setState({ + tabsByWorktree: { + 'folder-a': [makeTab({ id: 'tab-a', worktreeId: 'folder-a' })], + 'ssh-b': [makeTab({ id: 'tab-b', worktreeId: 'ssh-b' })] + }, + unifiedTabsByWorktree: {}, + agentStatusByPaneKey: { + [KEY_A]: entry(KEY_A, 'folder-a'), + [KEY_B]: { ...entry(KEY_B, 'ssh-b'), connectionId: 'ssh:host' } + }, + retainedAgentsByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + runtimePaneTitlesByTabId: {}, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + paneForegroundAgentByPaneKey: {} + }) + renders = 0 + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) +afterEach(() => { + act(() => root.unmount()) + container.remove() + useAppStore.setState(original, true) + vi.useRealTimers() +}) + +describe('mounted worktree agent row subscriptions', () => { + it('listens once while active and releases its listener while inactive', () => { + const baseline = readStoreListenerCount() ?? 0 + act(() => root.render()) + expect(readStoreListenerCount()).toBe(baseline + 1) + expect(rows[0].entry.prompt).toBe('folder-a') + act(() => root.render()) + expect(readStoreListenerCount()).toBe(baseline) + expect(rows).toEqual([]) + const before = renders + patch({ activeWorktreeId: 'ssh-b' }) + expect(renders).toBe(before) + patch({ + agentStatusByPaneKey: { + ...useAppStore.getState().agentStatusByPaneKey, + [KEY_A]: { ...entry(KEY_A, 'folder-a'), prompt: 'changed while disabled' } + } + }) + act(() => root.render()) + expect(rows[0].entry.prompt).toBe('changed while disabled') + expect(readStoreListenerCount()).toBe(baseline + 1) + }) + + it('switches worktrees and applies an SSH status update immediately', () => { + act(() => root.render()) + act(() => root.render()) + expect(rows[0].paneKey).toBe(KEY_B) + act(() => + useAppStore + .getState() + .setAgentStatus( + KEY_B, + { state: 'blocked', agentType: 'claude', prompt: 'remote permission' }, + undefined, + { updatedAt: Date.now() + 1 }, + { tabId: 'tab-b', worktreeId: 'ssh-b', connectionId: 'ssh:host' } + ) + ) + expect(rows[0].entry.prompt).toBe('remote permission') + expect(rows[0].state).toBe('blocked') + }) + + it('keeps unrelated cards stable while a working agent reports new tool output', () => { + act(() => root.render()) + const beforeRows = rows + const before = renders + act(() => + useAppStore + .getState() + .setAgentStatus( + KEY_B, + { state: 'working', agentType: 'claude', prompt: 'new output', toolName: 'Read' }, + undefined, + { updatedAt: Date.now() + 1 }, + { tabId: 'tab-b', worktreeId: 'ssh-b', connectionId: 'ssh:host' } + ) + ) + expect(rows).toBe(beforeRows) + expect(renders).toBe(before) + act(() => + useAppStore + .getState() + .setAgentStatus( + KEY_A, + { state: 'working', agentType: 'claude', prompt: 'local output', toolName: 'Edit' }, + undefined, + { updatedAt: Date.now() + 2 }, + { tabId: 'tab-a', worktreeId: 'folder-a' } + ) + ) + expect(rows[0].entry.toolName).toBe('Edit') + expect(renders).toBe(before + 1) + }) + + it('decays on an epoch-only freshness tick', () => { + act(() => root.render()) + expect(rows[0].state).toBe('working') + vi.setSystemTime(Date.now() + AGENT_STATUS_STALE_AFTER_MS + 1) + patch({ agentStatusEpoch: useAppStore.getState().agentStatusEpoch + 1 }) + expect(rows[0].state).toBe('idle') + }) + + it('observes title, PTY, layout, orchestration, foreground, migration and retention inputs', () => { + act(() => root.render()) + const updates: Partial[] = [ + { runtimePaneTitlesByTabId: { 'tab-a': { 0: 'Claude Code' } } }, + { ptyIdsByTabId: { 'tab-a': ['pty-a'] } }, + { + terminalLayoutsByTabId: { + 'tab-a': { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null + } + } + }, + { + runtimeAgentOrchestrationByPaneKey: { + [KEY_A]: { taskId: 'task', dispatchId: 'dispatch', taskTitle: 'New task' } + } + }, + { paneForegroundAgentByPaneKey: { [KEY_A]: { agent: 'claude', shellForeground: false } } }, + { + migrationUnsupportedByPtyId: { + legacy: { + ptyId: 'legacy', + worktreeId: 'folder-a', + tabId: 'tab-a', + leafId: LEAF, + paneKey: KEY_A, + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: Date.now() + } + } + }, + { + retainedAgentsByPaneKey: { + [KEY_B]: { + entry: entry(KEY_B, 'folder-a'), + worktreeId: 'folder-a', + tab: makeTab({ id: 'tab-b', worktreeId: 'folder-a' }), + agentType: 'claude', + startedAt: Date.now() + } + } + } + ] + for (const update of updates) { + const before = rows + patch(update) + expect(rows).not.toBe(before) + } + }) +}) diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts index 1fd1d8af452..d79a1bc2c3d 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts @@ -1,9 +1,9 @@ -import { useMemo } from 'react' -import { useShallow } from 'zustand/react/shallow' +import { useCallback, useMemo, useSyncExternalStore } from 'react' import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' import { EMPTY_LIVE_PTY_IDS, EMPTY_PANE_FOREGROUND_AGENTS, @@ -33,6 +33,7 @@ import { // Why frozen: shared by every inactive card, and callers only read these rows. const EMPTY_AGENT_ROWS = Object.freeze([]) as unknown as DashboardAgentRow[] +const subscribeToNothing = (): (() => void) => () => {} export { buildWorktreeAgentRows } from './worktree-agent-rows' export { @@ -53,63 +54,57 @@ export { * selector work on high-frequency agent status pings. */ export function useWorktreeAgentRows(worktreeId: string, active = true): DashboardAgentRow[] { - const selectAgentFreshness = useMemo( - () => createWorktreeAgentFreshnessSelector(worktreeId), - [worktreeId] - ) - const tabs = useAppStore((s) => (active ? s.tabsByWorktree[worktreeId] : EMPTY_TABS)) - // Why: narrow the subscriptions to only THIS worktree's entries via - // useShallow. Subscribing to the whole agentStatusByPaneKey map would make - // every on-screen card re-render on any agent-status update anywhere — - // O(worktrees²) render amplification. Pre-filtering here means the card - // only re-renders when something relevant to THIS worktree changes. - const liveEntries = useAppStore( - useShallow((s) => - active ? selectLiveAgentStatusEntriesForWorktree(s, worktreeId) : EMPTY_LIVE_ENTRIES - ) - ) - // Why: keep the store selector limited to stable raw records. Converting - // migration entries creates fresh objects with Date.now(), which breaks - // useSyncExternalStore's cached-snapshot contract and can blank Electron. - const migrationUnsupported = useAppStore( - useShallow((s) => - active - ? selectMigrationUnsupportedEntriesForWorktree(s, worktreeId) - : EMPTY_MIGRATION_UNSUPPORTED_ENTRIES - ) - ) - const retained = useAppStore( - useShallow((s) => - active ? selectRetainedAgentEntriesForWorktree(s, worktreeId) : EMPTY_RETAINED - ) - ) - const runtimePaneTitlesByTabId = useAppStore( - useShallow((s) => - active ? selectRuntimePaneTitlesForWorktree(s, worktreeId) : EMPTY_RUNTIME_PANE_TITLES - ) - ) - const ptyIdsByTabId = useAppStore( - useShallow((s) => (active ? selectLivePtyIdsForWorktree(s, worktreeId) : EMPTY_LIVE_PTY_IDS)) - ) - const terminalLayoutsByTabId = useAppStore( - useShallow((s) => - active ? selectTerminalLayoutsForWorktree(s, worktreeId) : EMPTY_TERMINAL_LAYOUTS - ) - ) - const runtimeAgentOrchestrationByPaneKey = useAppStore( - useShallow((s) => - active - ? selectRuntimeAgentOrchestrationForWorktree(s, worktreeId) - : EMPTY_WORKTREE_AGENT_ORCHESTRATION - ) - ) - const paneForegroundAgentByPaneKey = useAppStore( - useShallow((s) => - active ? selectPaneForegroundAgentsForWorktree(s, worktreeId) : EMPTY_PANE_FOREGROUND_AGENTS - ) - ) - const agentFreshnessSignature = useAppStore((s) => - active ? selectAgentFreshness(s) : EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE + const selectInputs = useMemo(() => { + const selectAgentFreshness = createWorktreeAgentFreshnessSelector(worktreeId) + const readInputs = (state: AppState) => + [ + active ? state.tabsByWorktree[worktreeId] : EMPTY_TABS, + active ? selectLiveAgentStatusEntriesForWorktree(state, worktreeId) : EMPTY_LIVE_ENTRIES, + active + ? selectMigrationUnsupportedEntriesForWorktree(state, worktreeId) + : EMPTY_MIGRATION_UNSUPPORTED_ENTRIES, + active ? selectRetainedAgentEntriesForWorktree(state, worktreeId) : EMPTY_RETAINED, + active ? selectRuntimePaneTitlesForWorktree(state, worktreeId) : EMPTY_RUNTIME_PANE_TITLES, + active ? selectLivePtyIdsForWorktree(state, worktreeId) : EMPTY_LIVE_PTY_IDS, + active ? selectTerminalLayoutsForWorktree(state, worktreeId) : EMPTY_TERMINAL_LAYOUTS, + active + ? selectRuntimeAgentOrchestrationForWorktree(state, worktreeId) + : EMPTY_WORKTREE_AGENT_ORCHESTRATION, + active + ? selectPaneForegroundAgentsForWorktree(state, worktreeId) + : EMPTY_PANE_FOREGROUND_AGENTS, + active ? selectAgentFreshness(state) : EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE + ] as const + let previous: ReturnType | undefined + return (state: AppState) => { + if (!active && previous) { + return previous + } + const next = readInputs(state) + // The indexed selectors preserve equal records; compare references without allocating shallow-comparison maps. + if (previous && next.every((value, index) => Object.is(value, previous?.[index]))) { + return previous + } + previous = next + return next + } + }, [worktreeId, active]) + const getInputs = useCallback(() => selectInputs(useAppStore.getState()), [selectInputs]) + const [ + tabs, + liveEntries, + migrationUnsupported, + retained, + runtimePaneTitlesByTabId, + ptyIdsByTabId, + terminalLayoutsByTabId, + runtimeAgentOrchestrationByPaneKey, + paneForegroundAgentByPaneKey, + agentFreshnessSignature + ] = useSyncExternalStore( + active ? useAppStore.subscribe : subscribeToNothing, + getInputs, + getInputs ) return useMemo(() => { diff --git a/src/renderer/src/components/sidebar/worktree-agent-rows-subscriptions.bench.test.tsx b/src/renderer/src/components/sidebar/worktree-agent-rows-subscriptions.bench.test.tsx new file mode 100644 index 00000000000..9f4142923b3 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-agent-rows-subscriptions.bench.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { writeFileSync } from 'node:fs' +import { expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { readStoreListenerCount } from '@/store/store-listener-census' +import { makeTab } from '@/store/slices/store-test-helpers' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { useWorktreeAgentRows } from './useWorktreeAgentRows' +import { useShallow } from 'zustand/react/shallow' +import { selectAcknowledgedAgentTimes } from './worktree-card-agent-ack-inputs' + +Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true) + +type Round = { elapsedMs: number; cpuMs: number; renders: number } +type Result = { active: boolean; listeners: number; rounds: Round[] } + +const LEAF = '11111111-1111-4111-8111-111111111111' +const CARDS = Number(process.env.ORCA_BENCH_CARDS ?? 100) +const WRITES = Number(process.env.ORCA_BENCH_WRITES ?? 500) + +it.skipIf(!process.env.ORCA_AGENT_ROWS_BENCH)( + 'measures mounted agent row subscriptions', + () => { + const original = useAppStore.getState() + const tabsByWorktree = Object.fromEntries( + Array.from({ length: CARDS }, (_, index) => { + const worktreeId = `wt-${index}` + return [worktreeId, [makeTab({ id: `tab-${index}`, worktreeId })]] + }) + ) + const entries: Record = {} + for (let index = 0; index < CARDS; index += 1) { + const paneKey = makePaneKey(`tab-${index}`, LEAF) + entries[paneKey] = { + paneKey, + tabId: `tab-${index}`, + worktreeId: `wt-${index}`, + state: 'working', + agentType: 'claude', + prompt: 'Do work', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [] + } + } + useAppStore.setState({ + tabsByWorktree, + agentStatusByPaneKey: entries, + unifiedTabsByWorktree: {}, + runtimePaneTitlesByTabId: {}, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, + retainedAgentsByPaneKey: {}, + runtimeAgentOrchestrationByPaneKey: {}, + paneForegroundAgentByPaneKey: {} + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const results: Result[] = [] + try { + // A visible compact card uses both hooks; hidden details keep only the inactive hook. + for (const active of [true, false]) { + let renders = 0 + function VisibleProbe({ index }: { index: number }) { + const rows = useWorktreeAgentRows(`wt-${index}`, true) + useWorktreeAgentRows(`wt-${index}`, false) + useAppStore(useShallow((state) => selectAcknowledgedAgentTimes(state, rows))) + renders += 1 + return null + } + function HiddenProbe({ index }: { index: number }) { + useWorktreeAgentRows(`wt-${index}`, false) + renders += 1 + return null + } + const Probe = active ? VisibleProbe : HiddenProbe + const baselineListeners = readStoreListenerCount() ?? 0 + act(() => + root.render( + Array.from({ length: CARDS }, (_, index) => ) + ) + ) + const listeners = (readStoreListenerCount() ?? 0) - baselineListeners + const rounds: Round[] = [] + for (let round = 0; round < 6; round += 1) { + const beforeRenders = renders + const cpuStart = process.cpuUsage() + const start = performance.now() + for (let index = 0; index < WRITES; index += 1) { + const key = makePaneKey(`tab-${index % CARDS}`, LEAF) + act(() => { + const previous = useAppStore.getState().agentStatusByPaneKey + useAppStore.setState({ + agentStatusByPaneKey: { + ...previous, + [key]: { ...previous[key], updatedAt: Date.now(), toolName: `tool-${index}` } + } + }) + }) + } + const elapsedMs = performance.now() - start + const cpu = process.cpuUsage(cpuStart) + expect(renders - beforeRenders).toBe(active ? WRITES : 0) + if (round > 0) { + rounds.push({ + elapsedMs, + cpuMs: (cpu.user + cpu.system) / 1000, + renders: renders - beforeRenders + }) + } + } + results.push({ active, listeners, rounds }) + act(() => root.render(null)) + expect(readStoreListenerCount()).toBe(baselineListeners) + } + const report = { cards: CARDS, writes: WRITES, results } + console.log(JSON.stringify(report)) + if (process.env.ORCA_BENCH_OUTPUT) { + writeFileSync(process.env.ORCA_BENCH_OUTPUT, `${JSON.stringify(report, null, 2)}\n`) + } + } finally { + act(() => root.unmount()) + container.remove() + useAppStore.setState(original, true) + } + }, + 120_000 +) diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.test.tsx b/src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.test.tsx index 0e0f53ee6f3..109ab4a0380 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.test.tsx @@ -75,6 +75,16 @@ describe('selectAcknowledgedAgentTimes', () => { expect(shallow(after, before)).toBe(true) }) + it('reuses the projection across status-only publications and handles a different row list', () => { + const state = { acknowledgedAgentsByPaneKey: { 'tab-a:leaf-a': 11, 'tab-a:leaf-b': 22 } } + const first = selectAcknowledgedAgentTimes(state, AGENTS) + expect(selectAcknowledgedAgentTimes({ ...state }, AGENTS)).toBe(first) + expect(selectAcknowledgedAgentTimes(state, [AGENTS[1], AGENTS[0]])).toEqual([22, 11]) + expect(selectAcknowledgedAgentTimes({ acknowledgedAgentsByPaneKey: {} }, AGENTS)).toEqual([ + 0, 0 + ]) + }) + it('updates when one of this card rows is acknowledged', () => { const initialTimes = selectedTimes act(() => { diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.ts b/src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.ts index cb24452364f..3fe7a53a8a7 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-ack-inputs.ts @@ -3,10 +3,30 @@ import type { AppState } from '@/store/types' type AcknowledgedAgentTimesState = Pick +type AcknowledgedAgentRows = readonly Pick[] + +// Both keys are weak so departed rows and old acknowledgement snapshots can be collected. +const projections = new WeakMap< + AcknowledgedAgentTimesState['acknowledgedAgentsByPaneKey'], + WeakMap +>() + /** Projects only the acknowledgement timestamps rendered by one card. */ export function selectAcknowledgedAgentTimes( state: AcknowledgedAgentTimesState, - agents: readonly Pick[] -): number[] { - return agents.map((agent) => state.acknowledgedAgentsByPaneKey[agent.paneKey] ?? 0) + agents: AcknowledgedAgentRows +): readonly number[] { + const source = state.acknowledgedAgentsByPaneKey + let byRows = projections.get(source) + if (!byRows) { + byRows = new WeakMap() + projections.set(source, byRows) + } + const cached = byRows.get(agents) + if (cached) { + return cached + } + const times = Object.freeze(agents.map((agent) => source[agent.paneKey] ?? 0)) + byRows.set(agents, times) + return times }