diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts index 896ecc88548..2e36c229a76 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-dispose-leak.test.ts @@ -101,4 +101,53 @@ describe('agent completion coordinator identity map stays bounded (leak regressi coordinator.dispose() // isLive() still true -> remount, not teardown expect(getAgentCompletionCoordinatorIdentityCountForTest()).toBe(1) }) + + it('retains stamped replay state when a mounted sibling disposes before remount', () => { + const paneKey = 'tab-1:leaf-production-remount' + const dispatchCompletion = vi.fn() + const hookLive = { value: true } + const paneLive = { value: true } + const hookCoordinator = createAgentCompletionCoordinator({ + ...makeOptions(paneKey, hookLive), + dispatchCompletion + }) + const paneCoordinator = createAgentCompletionCoordinator({ + ...makeOptions(paneKey, paneLive), + dispatchCompletion + }) + + hookCoordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_000 + }) + hookCoordinator.observeHookStatus({ + state: 'working', + prompt: 'review the PR', + agentType: 'claude', + stateStartedAt: 1_000, + turnCompletedAt: 2_000 + }) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + paneLive.value = false + paneCoordinator.dispose() + const remountedLive = { value: true } + const remounted = createAgentCompletionCoordinator({ + ...makeOptions(paneKey, remountedLive), + dispatchCompletion + }) + vi.advanceTimersByTime(2_000) + remounted.observeTitleWorking() + remounted.observeClassifiedTitleCompletion('Claude done') + + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + + hookLive.value = false + remountedLive.value = false + hookCoordinator.dispose() + remounted.dispose() + expect(getAgentCompletionCoordinatorIdentityCountForTest()).toBe(0) + }) }) diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts index 1e4ae501433..b4aa720ae69 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -41,6 +41,7 @@ const lastCompletionIdentityByPaneKey = new Map( const handledTurnCompletedAtByPaneKey = new Map() // Why: title fallback stays blocked only until the stamped background tail reaches its all-clear. const pendingStampedTailByPaneKey = new Map() +const coordinatorCountByPaneKey = new Map() const IDLE_POLL_INTERVAL_MS = 2_000 const ACTIVE_POLL_INTERVAL_MS = 750 @@ -82,6 +83,10 @@ function isAttentionHookState(state: ParsedAgentStatusPayload['state']): boolean export function createAgentCompletionCoordinator( options: AgentCompletionCoordinatorOptions ): AgentCompletionCoordinator { + coordinatorCountByPaneKey.set( + options.paneKey, + (coordinatorCountByPaneKey.get(options.paneKey) ?? 0) + 1 + ) let disposed = false let agentIdentityEstablished = false let hasAgentRunEvidence = false @@ -1033,13 +1038,22 @@ export function createAgentCompletionCoordinator( } function dispose(): void { + if (disposed) { + return + } disposed = true clearPollTimer() clearPendingHookDone() clearPendingCodexAttention() dropPendingTitle() - // Why: module-scoped identity survives a live-stream remount; evict only on genuine teardown (isLive() false) so it can't leak per closed pane. - if (!options.isLive()) { + const remainingCoordinators = (coordinatorCountByPaneKey.get(options.paneKey) ?? 1) - 1 + if (remainingCoordinators > 0) { + coordinatorCountByPaneKey.set(options.paneKey, remainingCoordinators) + } else { + coordinatorCountByPaneKey.delete(options.paneKey) + } + // Why: one pane can have hook and mounted coordinators; a remount disposal must not erase replay state still owned by its live sibling. + if (remainingCoordinators <= 0 && !options.isLive()) { lastCompletionIdentityByPaneKey.delete(options.paneKey) handledTurnCompletedAtByPaneKey.delete(options.paneKey) pendingStampedTailByPaneKey.delete(options.paneKey) @@ -1063,6 +1077,7 @@ export function resetAgentCompletionCoordinatorIdentitiesForTest(): void { lastCompletionIdentityByPaneKey.clear() handledTurnCompletedAtByPaneKey.clear() pendingStampedTailByPaneKey.clear() + coordinatorCountByPaneKey.clear() } export function getAgentCompletionCoordinatorIdentityCountForTest(): number {