diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts index 06ba3979ffe..0319e8a83b5 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts @@ -13,7 +13,7 @@ import { recordLiveEntriesFullRebuild } from './worktree-agent-live-index-patch' import { selectWorktreeAgentOrchestration } from './worktree-agent-orchestration-index' -import { createWorktreeRecordSelector } from './worktree-record-selector-cache' +import { createWorktreeRecordSelector } from '@/store/worktree-record-selector-cache' import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' // Why frozen and exported: card hooks return these from their inactive branch, diff --git a/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts b/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts index cd9f5c4f7c5..43971728deb 100644 --- a/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts +++ b/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts @@ -1,6 +1,6 @@ import type { AppState } from '@/store/types' import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' -import { createWorktreeRecordSelector } from './worktree-record-selector-cache' +import { createWorktreeRecordSelector } from '@/store/worktree-record-selector-cache' // Why: these selectors return fresh maps whose top-level values preserve // underlying per-tab references, so callers must compare them shallowly. diff --git a/src/renderer/src/components/terminal-pane/pane-title-update-global-scan-budget.test.tsx b/src/renderer/src/components/terminal-pane/pane-title-update-global-scan-budget.test.tsx index 94856e4685a..bea201455f7 100644 --- a/src/renderer/src/components/terminal-pane/pane-title-update-global-scan-budget.test.tsx +++ b/src/renderer/src/components/terminal-pane/pane-title-update-global-scan-budget.test.tsx @@ -33,7 +33,8 @@ import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking' -;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true) const WORKSPACE_COUNT = 870 const TERMINAL_TAB_COUNT = 1408 @@ -82,15 +83,16 @@ function buildTabsByWorktree(): Record { for (const [index, worktreeId] of worktreeIds.entries()) { const count = Math.min(remaining, index < MOUNTED_WORKTREE_COUNT ? 4 : 2) remaining -= count - tabsByWorktree[worktreeId] = Array.from( - { length: count }, - (_, tabIndex) => - ({ - id: `tab-${index}-${tabIndex}`, - title: `tab ${tabIndex}`, - ptyId: `${worktreeId}@@pty-${tabIndex}` - }) as TerminalTab - ) + tabsByWorktree[worktreeId] = Array.from({ length: count }, (_, tabIndex) => ({ + id: `tab-${index}-${tabIndex}`, + ptyId: `${worktreeId}@@pty-${tabIndex}`, + worktreeId, + title: `tab ${tabIndex}`, + customTitle: null, + color: null, + sortOrder: tabIndex, + createdAt: 0 + })) if (remaining <= 0) { break } @@ -107,15 +109,18 @@ function buildSleepingRecords(): Record { const worktreeId = worktreeIds[index % WORKSPACE_COUNT] const tabId = tabsByWorktree[worktreeId]?.[0]?.id ?? `tab-${index}-0` const paneKey = `${tabId}:1` - return [ + const record: SleepingAgentSessionRecord = { paneKey, - { - paneKey, - tabId, - worktreeId, - providerSession: { id: `session-${index}` } - } as unknown as SleepingAgentSessionRecord - ] as const + tabId, + worktreeId, + agent: 'claude', + providerSession: { key: 'session_id', id: `session-${index}` }, + prompt: 'prompt', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + return [paneKey, record] as const }), 'sleepingRecords' ) @@ -127,17 +132,16 @@ function buildAgentStatuses(): Record { Array.from({ length: AGENT_STATUS_COUNT }, (_, index) => { const tabId = seededTerminalTabs[index % seededTerminalTabs.length].id const paneKey = `${tabId}:1` - return [ + const entry: AgentStatusEntry = { paneKey, - { - paneKey, - state: 'working', - updatedAt: now, - stateStartedAt: now, - stateHistory: [], - agentType: 'claude' - } as unknown as AgentStatusEntry - ] as const + state: 'working', + prompt: 'prompt', + updatedAt: now, + stateStartedAt: now, + stateHistory: [], + agentType: 'claude' + } + return [paneKey, entry] as const }), 'agentStatusRows' ) @@ -175,7 +179,7 @@ function mountAtCaptureScale(): void { agentStatusEpoch: 1, activeWorktreeId: TARGET_WORKTREE_ID, runtimePaneTitlesByTabId: { [TARGET_TAB_ID]: { [TARGET_PANE_ID]: 'initial title' } } - } as never) + }) container = document.createElement('div') document.body.appendChild(container) @@ -212,10 +216,8 @@ afterEach(() => { useAppStore.setState(originalState, true) }) -// Why `it.fails` in this commit: it pins the before-state in history. The fix -// commit that follows flips both back to `it`. describe('one pane title update at live-capture scale', () => { - it.fails('walks no global inventory', () => { + it('walks no global inventory', () => { mountAtCaptureScale() reads.sleepingRecords = 0 reads.agentStatusRows = 0 @@ -227,7 +229,7 @@ describe('one pane title update at live-capture scale', () => { expect(MAIN_SLEEPING_RECORD_READS).toBe(SLEEPING_RECORD_COUNT * 23) }) - it.fails('stays flat as unrelated workspaces accumulate', () => { + it('stays flat as unrelated workspaces accumulate', () => { mountAtCaptureScale() reads.sleepingRecords = 0 applyOnePaneTitleUpdate('title a') @@ -246,7 +248,7 @@ describe('one pane title update at live-capture scale', () => { reads.sleepingRecords = 0 act(() => { - useAppStore.setState({ sleepingAgentSessionsByPaneKey: buildSleepingRecords() } as never) + useAppStore.setState({ sleepingAgentSessionsByPaneKey: buildSleepingRecords() }) }) // Why: correctness floor — a real inventory change must still be observed. diff --git a/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.test.ts b/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.test.ts index 0d731bc07f0..37fe50db1a1 100644 --- a/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.test.ts +++ b/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.test.ts @@ -19,6 +19,12 @@ function sleepingRecord( } } +function stateWith(sleepingAgentSessionsByPaneKey: Record): { + sleepingAgentSessionsByPaneKey: Record +} { + return { sleepingAgentSessionsByPaneKey } +} + describe('selectSleepingRecordParkExemptTabIds', () => { it.each([ [`tab-1:${LEAF_ID}`, 'tab-1'], @@ -26,14 +32,16 @@ describe('selectSleepingRecordParkExemptTabIds', () => { ])('derives the owner from a valid pane key (%s)', (paneKey, tabId) => { const records = { [paneKey]: sleepingRecord({ paneKey }) } - expect([...selectSleepingRecordParkExemptTabIds(records, 'wt-1')]).toEqual([tabId]) + expect([...selectSleepingRecordParkExemptTabIds(stateWith(records), 'wt-1')]).toEqual([tabId]) }) it('prefers the persisted tab id over the pane key owner', () => { const paneKey = `tab-stale:${LEAF_ID}` const records = { [paneKey]: sleepingRecord({ paneKey, tabId: 'tab-current' }) } - expect([...selectSleepingRecordParkExemptTabIds(records, 'wt-1')]).toEqual(['tab-current']) + expect([...selectSleepingRecordParkExemptTabIds(stateWith(records), 'wt-1')]).toEqual([ + 'tab-current' + ]) }) it('does not invent an owner for a delimiter-less pane key', () => { @@ -41,6 +49,30 @@ describe('selectSleepingRecordParkExemptTabIds', () => { 'orphan-pane-key': sleepingRecord({ paneKey: 'orphan-pane-key' }) } - expect([...selectSleepingRecordParkExemptTabIds(records, 'wt-1')]).toEqual([]) + expect([...selectSleepingRecordParkExemptTabIds(stateWith(records), 'wt-1')]).toEqual([]) + }) + + it('rebuilds when the record map changes and reuses the result when it does not', () => { + const paneKey = `tab-1:${LEAF_ID}` + const records = { [paneKey]: sleepingRecord({ paneKey }) } + const state = stateWith(records) + + const first = selectSleepingRecordParkExemptTabIds(state, 'wt-1') + expect(selectSleepingRecordParkExemptTabIds(state, 'wt-1')).toBe(first) + + const nextPaneKey = `tab-2:${LEAF_ID}` + const grown = stateWith({ ...records, [nextPaneKey]: sleepingRecord({ paneKey: nextPaneKey }) }) + + expect([...selectSleepingRecordParkExemptTabIds(grown, 'wt-1')]).toEqual(['tab-1', 'tab-2']) + }) + + // Why: a memo that serves a stale generation after the workspace's records are + // dropped would pin a hidden pane mounted for the rest of the session. + it('drops a worktree exemption once its records leave the map', () => { + const paneKey = `tab-1:${LEAF_ID}` + const populated = stateWith({ [paneKey]: sleepingRecord({ paneKey }) }) + expect([...selectSleepingRecordParkExemptTabIds(populated, 'wt-1')]).toEqual(['tab-1']) + + expect([...selectSleepingRecordParkExemptTabIds(stateWith({}), 'wt-1')]).toEqual([]) }) }) diff --git a/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.ts b/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.ts index 5a2ccec0e2a..78d3053a595 100644 --- a/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.ts +++ b/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.ts @@ -1,41 +1,58 @@ import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' import { isPassiveCompletedHibernationEvidence } from '../../lib/sleeping-agent-pane-ownership' +import { createWorktreeRecordSelector } from '@/store/worktree-record-selector-cache' const EMPTY_TAB_IDS: ReadonlySet = new Set() +export type SleepingRecordParkExemptionState = { + sleepingAgentSessionsByPaneKey?: Record | undefined +} + /** Tab ids whose panes own a sleeping record a mount can actually consume. * Why: a parked pane can never cold-restore, so per-tab parks must exempt * these — but only these: passive-completed records never resume, * and exempting them would pin a hidden pane mounted indefinitely. - * Callers subscribe through `useShallow`, which compares the set structurally, - * so a write for another worktree cannot re-render this one. Iterates in place — - * `Object.values` would allocate every record on every store write. */ -export function selectSleepingRecordParkExemptTabIds( - sleepingAgentSessionsByPaneKey: Record | undefined, - worktreeId: string -): ReadonlySet { - if (!sleepingAgentSessionsByPaneKey) { - return EMPTY_TAB_IDS + * + * Why memoized on the record-map identity (STA-7552): zustand re-runs every + * mounted subscriber's selector on every store write, so an unrelated pane + * title update used to walk the whole sleeping-record inventory once per + * retained worktree. The map only changes when a record is parked or + * consumed, so gating on its identity keeps a keystroke independent of how + * many sleeping agents the profile has accumulated. Callers still compare with + * `useShallow`; the carried generation makes that an identity hit. + * Iterates in place — `Object.values` would allocate every record per rebuild. */ +export const selectSleepingRecordParkExemptTabIds = createWorktreeRecordSelector< + SleepingRecordParkExemptionState, + ReadonlySet +>({ + readSources: (state) => [state.sleepingAgentSessionsByPaneKey], + empty: EMPTY_TAB_IDS, + isEmpty: (tabIds) => tabIds.size === 0, + build: (state, worktreeId) => { + const sleepingAgentSessionsByPaneKey = state.sleepingAgentSessionsByPaneKey + if (!sleepingAgentSessionsByPaneKey) { + return EMPTY_TAB_IDS + } + let owned: Set | null = null + for (const paneKey in sleepingAgentSessionsByPaneKey) { + const record = sleepingAgentSessionsByPaneKey[paneKey] + if (!record || record.worktreeId !== worktreeId) { + continue + } + if (isPassiveCompletedHibernationEvidence(record)) { + continue + } + // Why: malformed pane keys must yield no owner instead of a truncated tab id. + const tabId = + record.tabId ?? + parsePaneKey(record.paneKey)?.tabId ?? + parseLegacyNumericPaneKey(record.paneKey)?.tabId + if (tabId) { + owned ??= new Set() + owned.add(tabId) + } + } + return owned ?? EMPTY_TAB_IDS } - let owned: Set | null = null - for (const paneKey in sleepingAgentSessionsByPaneKey) { - const record = sleepingAgentSessionsByPaneKey[paneKey] - if (!record || record.worktreeId !== worktreeId) { - continue - } - if (isPassiveCompletedHibernationEvidence(record)) { - continue - } - // Why: malformed pane keys must yield no owner instead of a truncated tab id. - const tabId = - record.tabId ?? - parsePaneKey(record.paneKey)?.tabId ?? - parseLegacyNumericPaneKey(record.paneKey)?.tabId - if (tabId) { - owned ??= new Set() - owned.add(tabId) - } - } - return owned ?? EMPTY_TAB_IDS -} +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index 2376f842c9a..145a26690c7 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -119,9 +119,7 @@ export function useTerminalTabColdParking(args: { // Why the worktree-scoped set, not the record map: the map is app-global, so // subscribing to it re-rendered this worktree on every other worktree's write. const sleepingRecordOwnedTabIds = useAppStore( - useShallow((state) => - selectSleepingRecordParkExemptTabIds(state.sleepingAgentSessionsByPaneKey, worktreeId) - ) + useShallow((state) => selectSleepingRecordParkExemptTabIds(state, worktreeId)) ) const terminalTabHiddenSinceRef = useRef(new Map()) // Why: view switches hide every tab at once, so the park clock cannot rank them. diff --git a/src/renderer/src/components/sidebar/worktree-record-selector-cache.ts b/src/renderer/src/store/worktree-record-selector-cache.ts similarity index 90% rename from src/renderer/src/components/sidebar/worktree-record-selector-cache.ts rename to src/renderer/src/store/worktree-record-selector-cache.ts index 220d2d074bf..5e3c004b729 100644 --- a/src/renderer/src/components/sidebar/worktree-record-selector-cache.ts +++ b/src/renderer/src/store/worktree-record-selector-cache.ts @@ -34,6 +34,8 @@ export function createWorktreeRecordSelector(opti readSources: (state: TState) => readonly unknown[] build: (state: TState, worktreeId: string) => TValue empty: TValue + /** Override for values whose emptiness is not `Object.keys` — a Set, for instance. */ + isEmpty?: (value: TValue) => boolean }): (state: TState, worktreeId: string) => TValue { let generation: WorktreeRecordGeneration | null = null return (state, worktreeId) => { @@ -52,7 +54,7 @@ export function createWorktreeRecordSelector(opti const built = options.build(state, worktreeId) const carried = generation.carried?.get(worktreeId) let value = built - if (Object.keys(built).length === 0) { + if (options.isEmpty ? options.isEmpty(built) : Object.keys(built).length === 0) { value = options.empty } else if (carried && shallow(carried, built)) { value = carried