diff --git a/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts b/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts index 4e1bf41b6ab..06e8e900a2d 100644 --- a/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts +++ b/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { TerminalTab } from '../../../../shared/types' import type { RetainedAgentEntry } from './agent-status' +import { RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX } from './agent-status' import { createTestStore } from './store-test-helpers' // Why: dropAgentStatus and dismissRetainedAgentsByWorktree mirror the renderer- @@ -119,6 +120,24 @@ describe('dropAgentStatusByTabPrefix -> IPC fan-out', () => { 'tab-new': true }) }) + + it('FIFO-caps recentlyClosedAgentStatusTabIds so it cannot grow unbounded', () => { + stubWindowApi() + const store = createTestStore() + const cap = RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX + + for (let i = 0; i < cap + 5; i++) { + store.getState().dropAgentStatusByTabPrefix(`tab-${i}`) + } + + const closed = store.getState().recentlyClosedAgentStatusTabIds + expect(Object.keys(closed)).toHaveLength(cap) + // Oldest evicted, most-recent retained (a status event for a tab closed + // >cap tabs ago cannot still arrive, so suppression is unaffected). + expect(closed['tab-0']).toBeUndefined() + expect(closed['tab-4']).toBeUndefined() + expect(closed[`tab-${cap + 4}`]).toBe(true) + }) }) describe('dismissRetainedAgentsByWorktree → IPC fan-out', () => { diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 1ec81165c74..57cddf6a8f2 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -733,6 +733,35 @@ function getLaunchConfigForEntry( : undefined } +// Why: the renderer twin of the main-process closedAgentStatusTabIds set that +// #7561 FIFO-capped. It suppresses late hook/status events for a just-closed tab, +// so it must outlive the tab briefly — but tabId is ephemeral and it was only +// ever added to, growing one entry per tab-close for the renderer's whole life. +export const RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 + +// delete-then-set for LRU recency, then evict the oldest keys past the cap (Record +// key order is insertion order for non-integer string keys). A status event for a +// tab closed >MAX tabs ago cannot still arrive, so eviction is safe. +function boundRecentlyClosedAgentStatusTabIds( + existing: Record, + tabId: string +): Record { + const next: Record = {} + for (const key of Object.keys(existing)) { + if (key !== tabId) { + next[key] = true + } + } + next[tabId] = true + const keys = Object.keys(next) + if (keys.length > RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX) { + for (const stale of keys.slice(0, keys.length - RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX)) { + delete next[stale] + } + } + return next +} + function getLaunchConfigForStatusMetadata( state: AppState, metadata: AgentLaunchConfigStatusMetadata @@ -1632,10 +1661,10 @@ export const createAgentStatusSlice: StateCreator = { - ...s.recentlyClosedAgentStatusTabIds, - [tabIdPrefix]: true - } + const nextClosedTabs = boundRecentlyClosedAgentStatusTabIds( + s.recentlyClosedAgentStatusTabIds, + tabIdPrefix + ) if ( liveKeys.length === 0 &&