diff --git a/src/renderer/src/store/slices/pane-foreground-agent.test.ts b/src/renderer/src/store/slices/pane-foreground-agent.test.ts new file mode 100644 index 00000000000..b20239a8e34 --- /dev/null +++ b/src/renderer/src/store/slices/pane-foreground-agent.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { TerminalTab } from '../../../../shared/types' + +function terminalTab(id: string, worktreeId: string): TerminalTab { + return { + id, + ptyId: null, + worktreeId, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +describe('pane foreground agent slice', () => { + it('sets, value-bails, and clears entries per pane key', () => { + const store = createTestStore() + store + .getState() + .setPaneForegroundAgent('tab-1:leaf-1', { agent: 'aider', shellForeground: false }) + const first = store.getState().paneForegroundAgentByPaneKey + + store + .getState() + .setPaneForegroundAgent('tab-1:leaf-1', { agent: 'aider', shellForeground: false }) + expect(store.getState().paneForegroundAgentByPaneKey).toBe(first) + + store.getState().clearPaneForegroundAgent('tab-1:leaf-1') + expect(store.getState().paneForegroundAgentByPaneKey).toEqual({}) + }) + + it('sweeps only the closed tab prefix, not sibling tabs or prefix-share ids', () => { + const store = createTestStore() + store + .getState() + .setPaneForegroundAgent('tab-1:leaf-1', { agent: 'aider', shellForeground: false }) + store + .getState() + .setPaneForegroundAgent('tab-10:leaf-1', { agent: 'codex', shellForeground: false }) + + store.getState().clearPaneForegroundAgentByTabPrefix('tab-1') + + expect(Object.keys(store.getState().paneForegroundAgentByPaneKey)).toEqual(['tab-10:leaf-1']) + }) + + it('sweeps every tab of a worktree on wholesale teardown', () => { + const store = createTestStore() + store.setState({ + tabsByWorktree: { + 'wt-1': [terminalTab('tab-1', 'wt-1'), terminalTab('tab-2', 'wt-1')], + 'wt-2': [terminalTab('tab-3', 'wt-2')] + } + }) + store + .getState() + .setPaneForegroundAgent('tab-1:leaf-1', { agent: 'aider', shellForeground: false }) + store.getState().setPaneForegroundAgent('tab-2:leaf-1', { agent: null, shellForeground: true }) + store + .getState() + .setPaneForegroundAgent('tab-3:leaf-1', { agent: 'codex', shellForeground: false }) + + const before = store.getState().paneForegroundAgentByPaneKey + store.getState().clearPaneForegroundAgentByWorktree('wt-missing') + expect(store.getState().paneForegroundAgentByPaneKey).toBe(before) + + store.getState().clearPaneForegroundAgentByWorktree('wt-1') + + expect(Object.keys(store.getState().paneForegroundAgentByPaneKey)).toEqual(['tab-3:leaf-1']) + }) +}) diff --git a/src/renderer/src/store/slices/pane-foreground-agent.ts b/src/renderer/src/store/slices/pane-foreground-agent.ts index 514b5c3e73d..789e41cd86d 100644 --- a/src/renderer/src/store/slices/pane-foreground-agent.ts +++ b/src/renderer/src/store/slices/pane-foreground-agent.ts @@ -19,6 +19,10 @@ export type PaneForegroundAgentSlice = { paneForegroundAgentByPaneKey: Record setPaneForegroundAgent: (paneKey: string, entry: PaneForegroundAgentEntry) => void clearPaneForegroundAgent: (paneKey: string) => void + /** Wholesale teardown sweeps (tab close, worktree sleep/remove) retire pane + * keys without per-pane close events — clear their entries too. */ + clearPaneForegroundAgentByTabPrefix: (tabIdPrefix: string) => void + clearPaneForegroundAgentByWorktree: (worktreeId: string) => void } export const createPaneForegroundAgentSlice: StateCreator< @@ -52,5 +56,34 @@ export const createPaneForegroundAgentSlice: StateCreator< delete next[paneKey] return { paneForegroundAgentByPaneKey: next } }) + }, + clearPaneForegroundAgentByTabPrefix: (tabIdPrefix) => { + set((s) => clearEntriesByTabPrefixes(s.paneForegroundAgentByPaneKey, [`${tabIdPrefix}:`]) ?? s) + }, + clearPaneForegroundAgentByWorktree: (worktreeId) => { + set((s) => { + const prefixes = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) + return clearEntriesByTabPrefixes(s.paneForegroundAgentByPaneKey, prefixes) ?? s + }) } }) + +function clearEntriesByTabPrefixes( + entries: Record, + tabPrefixes: string[] +): Pick | null { + if (tabPrefixes.length === 0) { + return null + } + const staleKeys = Object.keys(entries).filter((paneKey) => + tabPrefixes.some((prefix) => paneKey.startsWith(prefix)) + ) + if (staleKeys.length === 0) { + return null + } + const next = { ...entries } + for (const paneKey of staleKeys) { + delete next[paneKey] + } + return { paneForegroundAgentByPaneKey: next } +} diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index bbd43ff773b..b34a1d8c5f7 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -1184,6 +1184,9 @@ export const createTerminalSlice: StateCreator // so retention suppressors are planted: a live→gone transition inside the // same frame as the tab close cannot re-snapshot a row we just dropped. get().dropAgentStatusByTabPrefix(tabId) + // Why: retired pane keys never recur, so stranded foreground entries would + // accumulate for the renderer's whole lifetime. + get().clearPaneForegroundAgentByTabPrefix(tabId) // Why: closing a tab permanently retires every pane under it (a reopen mints // a fresh leafId at epoch 0), so drop the panes' hibernation output epochs to // keep that module-level map from growing for the renderer's whole lifetime. @@ -2349,6 +2352,7 @@ export const createTerminalSlice: StateCreator sleepingPaneKeys: opts?.sleepingPaneKeys, retainedCompletionEvidence }) + get().clearPaneForegroundAgentByWorktree(worktreeId) if (ptyIds.length === 0 && expectedRuntimePtyIds.length === 0) { return