Sweep pane foreground entries on tab close and worktree teardown

Wholesale teardowns (tab close, worktree sleep/remove) retire pane keys
without per-pane close events, stranding unreachable foreground entries
for the renderer's lifetime. Mirror the agent-status prefix sweeps at
both chokepoints; no-op sweeps bail without a state change.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-07-03 14:28:05 -07:00
co-authored by Orca
parent 621f0829be
commit 3a9cf1467f
3 changed files with 110 additions and 0 deletions
@@ -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'])
})
})
@@ -19,6 +19,10 @@ export type PaneForegroundAgentSlice = {
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry>
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<string, PaneForegroundAgentEntry>,
tabPrefixes: string[]
): Pick<PaneForegroundAgentSlice, 'paneForegroundAgentByPaneKey'> | 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 }
}
@@ -1184,6 +1184,9 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
// 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<AppState, [], [], TerminalSlice>
sleepingPaneKeys: opts?.sleepingPaneKeys,
retainedCompletionEvidence
})
get().clearPaneForegroundAgentByWorktree(worktreeId)
if (ptyIds.length === 0 && expectedRuntimePtyIds.length === 0) {
return