diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts index ed3aa384b9d..f4dd435535f 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts @@ -191,7 +191,8 @@ export function buildRetractedMirroredTabSweepPatch( retentionSuppressedPaneKeys: state.retentionSuppressedPaneKeys ?? {}, sortEpoch: agentStatusPatch?.sortEpoch ?? state.sortEpoch, // Keep the sweep state consistent with the accepted host inventory. - tabsByWorktree: nextTabsByWorktree + tabsByWorktree: nextTabsByWorktree, + unifiedTabsByWorktree: state.unifiedTabsByWorktree } // Why: a retraction can be a reconnect re-key, not pane death (ssh-execution-boundary); keeping // cutoffs means a republished pane cannot replay activity the user cleared on this client. diff --git a/src/renderer/src/store/slices/agent-status-drop-reducer.ts b/src/renderer/src/store/slices/agent-status-drop-reducer.ts index ceaefc34355..4f85fe292c1 100644 --- a/src/renderer/src/store/slices/agent-status-drop-reducer.ts +++ b/src/renderer/src/store/slices/agent-status-drop-reducer.ts @@ -41,6 +41,7 @@ export type AgentStatusTabPrefixDropState = Pick< | 'retentionSuppressedPaneKeys' | 'sortEpoch' | 'tabsByWorktree' + | 'unifiedTabsByWorktree' > /** Pure form of the dropAgentStatusByTabPrefix reducer: the paired snapshot diff --git a/src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts b/src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts index 2377cd52950..f25478b6025 100644 --- a/src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts +++ b/src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts @@ -75,6 +75,12 @@ export function findCompletedOrphanPaneKeysForTabClose( return [] } const openTabIds = new Set((state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)) + // Terminal owners must still exist in the terminal inventory; unified metadata can lag a close. + for (const tab of state.unifiedTabsByWorktree[worktreeId] ?? []) { + if (tab.contentType !== 'terminal') { + openTabIds.add(tab.id) + } + } const paneKeys: string[] = [] for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { if (paneKey.startsWith(prefix) || entry.state !== 'done' || entry.worktreeId !== worktreeId) { diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index d450e037185..18a07ad622c 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -4,9 +4,18 @@ import { type AgentStatusEntry } from '../../../../shared/agent-status-types' import { flushMicrotasks } from './agent-status-test-harness' -import { createTestStore, makeTab } from './store-test-helpers' +import { createTestStore, makeTab, makeUnifiedTab } from './store-test-helpers' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { RetainedAgentEntry } from './agent-status' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionTabId +} from '../../../../shared/structured-agent-session-projection' +import { buildAgentStatusTabPrefixDropPatch } from './agent-status-drop-reducer' + +vi.mock('@/runtime/structured-agent-session-tab-retirement', () => ({ + beginStructuredAgentSessionTabClose: vi.fn() +})) { describe('agent status freshness expiry', () => { @@ -336,6 +345,9 @@ import type { RetainedAgentEntry } from './agent-status' makeTab({ id: 'tab-live', worktreeId: 'wt-1' }) ], 'wt-2': [] + }, + unifiedTabsByWorktree: { + 'wt-1': [makeUnifiedTab({ id: 'tab-orphan', worktreeId: 'wt-1', groupId: 'group-1' })] } }) store @@ -392,6 +404,110 @@ import type { RetainedAgentEntry } from './agent-status' expect(s.agentStatusByPaneKey['tab-other-orphan:0']).toBeDefined() }) + it.each(['wt-1', 'folder-workspace-1'])( + 'preserves an open chat and its read state when an unrelated terminal closes in %s', + (worktreeId) => { + vi.useFakeTimers() + const store = createTestStore() + const sessionId = 'chat-1' + const chat = makeUnifiedTab({ + id: structuredAgentSessionTabId(sessionId), + entityId: sessionId, + contentType: 'agent-session', + executionHostId: 'local', + worktreeId, + groupId: 'group-1' + }) + const paneKey = structuredAgentSessionPaneKey(chat.id, sessionId) + const missingOwnerPaneKey = structuredAgentSessionPaneKey( + structuredAgentSessionTabId('missing-chat'), + 'missing-chat' + ) + store.setState({ + tabsByWorktree: { + [worktreeId]: [ + makeTab({ id: 'tab-closed', worktreeId }), + makeTab({ id: 'tab-live', worktreeId }) + ] + }, + unifiedTabsByWorktree: { [worktreeId]: [chat] } + }) + for (const key of [paneKey, missingOwnerPaneKey]) { + store + .getState() + .setAgentStatus( + key, + { state: 'done', prompt: 'completed chat', agentType: 'codex' }, + undefined, + undefined, + { worktreeId } + ) + } + store.getState().acknowledgeAgents([paneKey, missingOwnerPaneKey]) + store.setState({ + activityClearedAtByPaneKey: { [paneKey]: 100, [missingOwnerPaneKey]: 100 }, + manuallyUnreadTurnsByPaneKey: { [paneKey]: 200, [missingOwnerPaneKey]: 200 } + }) + const before = store.getState() + + store.getState().closeTab('tab-closed') + + const after = store.getState() + expect(after.agentStatusByPaneKey[paneKey]).toBe(before.agentStatusByPaneKey[paneKey]) + expect(after.acknowledgedAgentsByPaneKey[paneKey]).toBe( + before.acknowledgedAgentsByPaneKey[paneKey] + ) + expect(after.activityClearedAtByPaneKey[paneKey]).toBe(100) + expect(after.manuallyUnreadTurnsByPaneKey[paneKey]).toBe(200) + expect(after.agentStatusByPaneKey[missingOwnerPaneKey]).toBeUndefined() + expect(after.acknowledgedAgentsByPaneKey[missingOwnerPaneKey]).toBeUndefined() + expect(after.activityClearedAtByPaneKey[missingOwnerPaneKey]).toBeUndefined() + expect(after.manuallyUnreadTurnsByPaneKey[missingOwnerPaneKey]).toBeUndefined() + + store.getState().closeUnifiedTab(chat.id) + store.getState().closeTab('tab-live') + + const closed = store.getState() + expect(closed.unifiedTabsByWorktree[worktreeId]).toEqual([]) + expect(closed.agentStatusByPaneKey[paneKey]).toBeUndefined() + expect(closed.acknowledgedAgentsByPaneKey[paneKey]).toBeUndefined() + expect(closed.activityClearedAtByPaneKey[paneKey]).toBeUndefined() + expect(closed.manuallyUnreadTurnsByPaneKey[paneKey]).toBeUndefined() + } + ) + + it('keeps an exact-pane remote retirement scoped to that pane', () => { + vi.useFakeTimers() + const store = createTestStore() + const retiringPaneKey = 'host-tab:11111111-1111-4111-8111-111111111111' + const siblingPaneKey = 'host-tab:22222222-2222-4222-8222-222222222222' + for (const paneKey of [retiringPaneKey, siblingPaneKey, 'missing-owner:0']) { + store + .getState() + .setAgentStatus( + paneKey, + { state: 'done', prompt: 'remote chat', agentType: 'codex' }, + undefined, + undefined, + { worktreeId: 'wt-1' } + ) + } + const before = store.getState() + + const { patch } = buildAgentStatusTabPrefixDropPatch(before, 'host-tab', [], { + worktreeId: 'wt-1', + paneKeys: new Set([retiringPaneKey]) + }) + + expect(patch.agentStatusByPaneKey?.[retiringPaneKey]).toBeUndefined() + expect(patch.agentStatusByPaneKey?.[siblingPaneKey]).toBe( + before.agentStatusByPaneKey[siblingPaneKey] + ) + expect(patch.agentStatusByPaneKey?.['missing-owner:0']).toBe( + before.agentStatusByPaneKey['missing-owner:0'] + ) + }) + it('on a paneKey with neither live nor retained entry: no-op (same state reference, no epoch bumps)', () => { vi.useFakeTimers() const store = createTestStore()