From 299dcb86fc8a3e09286a5fb2df5517a02b3c3cf8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:29:17 -0700 Subject: [PATCH] fix(worktrees): a host-scoped purge leaves a surviving twin's tabs and edits alone (#30030) --- config/focus-setting-read-baseline.txt | 7 +- .../worktree-event-lineage-refresh.test.ts | 3 +- .../ipc-events/worktree-event-runtime.ts | 3 +- .../folder-workspace-catalog.ts | 8 +- .../src/store/host-twin-isolation.test.ts | 9 +- .../store/host-twin-worktree-purge.test.ts | 374 ++++++++++++++++++ .../repos/repo-removal-host-twin-teardown.ts | 2 +- .../tabs/kept-worktree-surface-pruning.ts | 127 ++++++ .../slices/tabs/worktree-layout-lifecycle.ts | 102 ++++- .../store/slices/terminal-tab-retirement.ts | 6 +- .../src/store/slices/worktree-purge-target.ts | 18 +- .../listing/fetched-worktree-merge.ts | 3 +- .../session/worktree-slice-lookups.ts | 2 +- .../teardown/host-twin-worktree-purge.ts | 250 ++++++++++++ .../purge-stale-runtime-host-state.ts | 6 +- .../purged-structured-launch-retirement.ts | 89 +++++ .../teardown/worktree-purge-doomed-ids.ts | 62 +-- .../teardown/worktree-purge-state.ts | 213 +++------- .../teardown/worktree-purge-surface-patch.ts | 88 +++++ .../worktree/host-qualified-identity.test.ts | 19 +- .../worktree/host-qualified-identity.ts | 17 + 21 files changed, 1198 insertions(+), 210 deletions(-) create mode 100644 src/renderer/src/store/host-twin-worktree-purge.test.ts create mode 100644 src/renderer/src/store/slices/tabs/kept-worktree-surface-pruning.ts create mode 100644 src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts create mode 100644 src/renderer/src/store/slices/worktrees/teardown/purged-structured-launch-retirement.ts create mode 100644 src/renderer/src/store/slices/worktrees/teardown/worktree-purge-surface-patch.ts diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index da250b6fcb2..540f24e6830 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -459,21 +459,18 @@ 1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts 3 src/renderer/src/store/slices/worktree-visibility-settings-write.ts 3 src/renderer/src/store/slices/worktrees-slice-test-harness.ts -7 src/renderer/src/store/slices/worktrees.ts +5 src/renderer/src/store/slices/worktrees.ts 1 src/renderer/src/store/slices/worktrees/create/create-worktree.ts 1 src/renderer/src/store/slices/worktrees/create/created-worktree-state-merge.ts 2 src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts 2 src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts -1 src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts 2 src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts 2 src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts 1 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts 3 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-host-merge.ts 1 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts -1 src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts 3 src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts -1 src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts -1 src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +1 src/renderer/src/store/slices/worktrees/teardown/purged-structured-launch-retirement.ts 2 src/renderer/src/store/terminals/terminal-active-workspace-creation.ts 1 src/renderer/src/store/terminals/terminal-pane-hibernation.ts 1 src/renderer/src/store/terminals/terminal-shutdown.ts diff --git a/src/renderer/src/hooks/ipc-events/worktree-event-lineage-refresh.test.ts b/src/renderer/src/hooks/ipc-events/worktree-event-lineage-refresh.test.ts index 3bb28b6fd40..fdaac34f596 100644 --- a/src/renderer/src/hooks/ipc-events/worktree-event-lineage-refresh.test.ts +++ b/src/renderer/src/hooks/ipc-events/worktree-event-lineage-refresh.test.ts @@ -14,6 +14,7 @@ import { import { replaceRuntimeEnvironmentRevisions } from '../../runtime/runtime-environment-revision' import { advanceRuntimeEnvironmentConnectionGeneration } from '../../store/slices/runtime-status-connection-generation' import { createWorktreeEventRuntime } from './worktree-event-runtime' +import { hostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity' import { beginWorktreeLineageRefresh, invalidateRemoteWorktreeLineage, @@ -219,7 +220,7 @@ describe('remote worktree event lineage refresh', () => { expect(store.getState().fetchWorktreeLineage).toHaveBeenCalledTimes(2) expect(store.getState().setActiveWorktree).toHaveBeenCalledWith('repo-rename::/new') expect(store.getState().purgeWorktreeTerminalState).toHaveBeenCalledWith([ - { id: deleted.id, hostId: 'runtime:env-1' } + hostWorktreeTarget({ id: deleted.id, hostId: 'runtime:env-1' }) ]) expect(store.getState().removeWorkspaceSpaceWorktrees).toHaveBeenCalledWith([deleted.id]) }) diff --git a/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts b/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts index 44a923fcb28..d5bfd8e1216 100644 --- a/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts +++ b/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts @@ -6,6 +6,7 @@ import { } from '../../../../shared/execution-host' import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events' import type { AppState } from '../../store/types' +import { hostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity' import type { WorktreeSortOrderUpdate } from '../../../../shared/worktree/sort-order-update' import { hasHostRowsForSortOrders, @@ -189,7 +190,7 @@ export function createWorktreeEventRuntime( // A forced-local refresh owns only the local host's panes. (options?.forceLocalOwner ? LOCAL_EXECUTION_HOST_ID : undefined) afterState.purgeWorktreeTerminalState( - purgeHostId ? removed.map((id) => ({ id, hostId: purgeHostId })) : removed + purgeHostId ? removed.map((id) => hostWorktreeTarget({ id, hostId: purgeHostId })) : removed ) afterState.removeWorkspaceSpaceWorktrees(removed) } diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-catalog.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-catalog.ts index dc84a9545f3..72a457c6809 100644 --- a/src/renderer/src/store/folder-workspaces/folder-workspace-catalog.ts +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-catalog.ts @@ -9,6 +9,7 @@ import { type ExecutionHostId } from '../../../../shared/execution-host' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { hostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity' import { catalogOwnsHost, getProjectGroupHostId } from '../slices/project-group-owner-routing' import type { FolderWorkspaceUpdateTicket } from '../slices/folder-workspace-update-coordinator' import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' @@ -267,7 +268,12 @@ export async function reconcileFailedFolderWorkspaceUpdate(args: { : {}) })) if (!refreshed) { - args.get().purgeWorktreeTerminalState([folderWorkspaceKey(args.folderWorkspaceId)]) + args.get().purgeWorktreeTerminalState([ + hostWorktreeTarget({ + id: folderWorkspaceKey(args.folderWorkspaceId), + hostId: args.ownerHostId + }) + ]) } } catch (err) { console.warn('Failed to reconcile folder workspace after update failure:', err) diff --git a/src/renderer/src/store/host-twin-isolation.test.ts b/src/renderer/src/store/host-twin-isolation.test.ts index ca61cf6da8f..fa05b714b6e 100644 --- a/src/renderer/src/store/host-twin-isolation.test.ts +++ b/src/renderer/src/store/host-twin-isolation.test.ts @@ -6,6 +6,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionHostId } from '../../../shared/execution-host' +import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' import { createCompatibleRuntimeStatusResponseIfNeeded, type RuntimeEnvironmentCallRequest @@ -149,7 +150,9 @@ const TWIN_ACTIONS: readonly TwinAction[] = [ run: async (f, target) => f.store .getState() - .purgeWorktreeTerminalState([{ id: f.workspaceKey, hostId: target.hostId }]), + .purgeWorktreeTerminalState([ + hostWorktreeTarget({ id: f.workspaceKey, hostId: target.hostId }) + ]), applied: (f, target) => expect(f.store.getState().ptyIdsByTabId[target.terminalTabId]).toBeUndefined() }, @@ -202,10 +205,6 @@ function everyCell(actionId: string, reason: string, kinds = TWIN_KINDS): [strin /** ` | | ` → why the survivor is touched today (tracker Stage 1 worklist). */ const KNOWN_TWIN_LEAKS: Readonly> = Object.fromEntries([ - ...everyCell( - 'purge terminal state', - 'the purge drops every bucket under the raw id; the target host only scopes visit recency' - ), ...everyCell('kill terminals', 'takes a raw worktree id, so it stops both twins’ PTYs'), ...everyCell('sleep', 'takes a raw worktree id, so it sleeps both twins'), [ diff --git a/src/renderer/src/store/host-twin-worktree-purge.test.ts b/src/renderer/src/store/host-twin-worktree-purge.test.ts new file mode 100644 index 00000000000..23adee86b19 --- /dev/null +++ b/src/renderer/src/store/host-twin-worktree-purge.test.ts @@ -0,0 +1,374 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { hostWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' +import { getWorktreeVisitKey } from '@/lib/worktree-visit-recency' +import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' +import { resetHostTwinModuleStores } from './host-twin-state-projection.test-fixture' +import { + createHostTwinFixture, + TWIN_RUNTIME_ENV, + type HostTwinFixture +} from './host-twin.test-fixture' + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +let current: HostTwinFixture | null = null + +beforeEach(() => { + vi.stubGlobal('window', { api: { pty: { kill: vi.fn() } } }) +}) + +afterEach(() => { + if (current) { + resetHostTwinModuleStores(current) + } + current = null + vi.unstubAllGlobals() +}) + +function twin(kind: Parameters[0] = 'local+ssh'): HostTwinFixture { + const fixture = createHostTwinFixture(kind) + current = fixture + const key = fixture.workspaceKey + fixture.store.setState({ + gitStatusByWorktree: { [key]: [] }, + activeWorktreeId: key, + activeWorkspaceExecutionHostId: fixture.remote.hostId, + lastVisitedAtByWorktreeId: { + [getWorktreeVisitKey(key, fixture.local.hostId)]: 1, + [getWorktreeVisitKey(key, fixture.remote.hostId)]: 2 + } + }) + return fixture +} + +function keepOnlyRemoteRow(fixture: HostTwinFixture): void { + fixture.store.setState((s) => ({ + worktreesByRepo: { + [fixture.projectId]: (s.worktreesByRepo[fixture.projectId] ?? []).filter( + (row) => row.hostId === fixture.remote.hostId + ) + } + })) +} + +describe('purgeWorktreeTerminalState with a surviving twin', () => { + it("purges only the target host's attributable state", () => { + const f = twin() + const key = f.workspaceKey + + f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })]) + + const s = f.store.getState() + expect(s.tabsByWorktree[key]?.map((tab) => tab.id)).toEqual([ + f.local.terminalTabId, + f.dormantTabId + ]) + expect(s.ptyIdsByTabId[f.remote.terminalTabId]).toBeUndefined() + expect(s.terminalLayoutsByTabId[f.remote.terminalTabId]).toBeUndefined() + expect(s.ptyIdsByTabId[f.local.terminalTabId]).toEqual([f.local.ptyId]) + expect(s.unifiedTabsByWorktree[key]?.map((tab) => tab.id)).toEqual([ + f.local.terminalTabId, + f.local.fileId, + f.dormantTabId + ]) + expect(s.groupsByWorktree[key]?.[0]?.tabOrder).toEqual([ + f.local.terminalTabId, + f.local.fileId, + f.dormantTabId + ]) + expect(s.openFiles.map((file) => file.id)).toEqual([f.local.fileId]) + expect(Object.keys(s.editorDrafts)).toEqual([f.local.fileId]) + expect(Object.keys(s.sleepingAgentSessionsByPaneKey)).toEqual([f.local.paneKey]) + // Shared buckets and raw-id module stores cannot name a host, so the twin keeps them. + expect(s.gitStatusByWorktree[key]).toEqual([]) + expect(hasWorktreeSleepIntent(key)).toBe(true) + expect(Object.keys(s.lastVisitedAtByWorktreeId)).toEqual([ + getWorktreeVisitKey(key, f.local.hostId) + ]) + expect(s.activeWorktreeId).toBe(key) + expect(s.activeWorkspaceExecutionHostId).toBe(f.local.hostId) + }) + + it('keeps PTY-keyed state for a purged PTY that a kept tab still binds', () => { + const f = twin() + const shared = f.remote.ptyId + // A mixed-host split: the kept local tab also binds the purged copy's PTY. + f.store.setState((s) => ({ + ptyIdsByTabId: { ...s.ptyIdsByTabId, [f.local.terminalTabId]: [f.local.ptyId, shared] }, + suppressedPtyExitIds: { [shared]: true }, + pendingPaneAccountRestarts: { [shared]: 'codex' } + })) + + f.store + .getState() + .purgeWorktreeTerminalState([hostWorktreeTarget({ id: f.workspaceKey, ...f.remote })]) + + const s = f.store.getState() + expect(s.ptyIdsByTabId[f.local.terminalTabId]).toEqual([f.local.ptyId, shared]) + expect(s.suppressedPtyExitIds).toEqual({ [shared]: true }) + expect(s.pendingPaneAccountRestarts).toEqual({ [shared]: 'codex' }) + }) + + it('collapses a split the purged copy emptied, handing selection to the survivor', () => { + const f = twin() + const key = f.workspaceKey + f.store.setState((s) => { + const [group] = s.groupsByWorktree[key] ?? [] + if (!group) { + throw new Error('fixture has no tab group') + } + const remoteIds = [f.remote.terminalTabId, f.remote.fileId] + return { + groupsByWorktree: { + [key]: [ + { ...group, tabOrder: group.tabOrder.filter((id) => !remoteIds.includes(id)) }, + { ...group, id: 'remote-group', tabOrder: remoteIds, activeTabId: remoteIds[0] } + ] + }, + unifiedTabsByWorktree: { + [key]: (s.unifiedTabsByWorktree[key] ?? []).map((tab) => + remoteIds.includes(tab.id) ? { ...tab, groupId: 'remote-group' } : tab + ) + }, + layoutByWorktree: { + [key]: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: group.id }, + second: { type: 'leaf', groupId: 'remote-group' } + } + }, + activeGroupIdByWorktree: { [key]: 'remote-group' } + } + }) + + f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })]) + + const s = f.store.getState() + const survivingGroupIds = (s.groupsByWorktree[key] ?? []).map((group) => group.id) + expect(survivingGroupIds).not.toContain('remote-group') + expect(survivingGroupIds).toHaveLength(1) + expect(s.layoutByWorktree[key]).not.toEqual(expect.objectContaining({ type: 'split' })) + expect(s.activeGroupIdByWorktree[key]).toBe(survivingGroupIds[0]) + }) + + it('keeps an untouched active group when it collapses a split the purge emptied', () => { + const f = twin() + const key = f.workspaceKey + const remoteIds = [f.remote.terminalTabId, f.remote.fileId] + f.store.setState((s) => { + const [group] = s.groupsByWorktree[key] ?? [] + if (!group) { + throw new Error('fixture has no tab group') + } + const localIds = group.tabOrder.filter((id) => !remoteIds.includes(id)) + return { + groupsByWorktree: { + [key]: [ + { ...group, id: 'a', tabOrder: localIds.slice(0, 1), activeTabId: localIds[0] }, + { ...group, id: 'b', tabOrder: localIds.slice(1), activeTabId: localIds[1] }, + { ...group, id: 'c', tabOrder: remoteIds, activeTabId: remoteIds[0] } + ] + }, + layoutByWorktree: { + [key]: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'a' }, + second: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', groupId: 'b' }, + second: { type: 'leaf', groupId: 'c' } + } + } + }, + activeGroupIdByWorktree: { [key]: 'a' } + } + }) + + f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })]) + + const s = f.store.getState() + expect((s.groupsByWorktree[key] ?? []).map((group) => group.id)).toEqual(['a', 'b']) + expect(s.activeGroupIdByWorktree[key]).toBe('a') + }) + + it('keeps the runtime twin when the local copy is purged', () => { + const f = twin('local+runtime') + const key = f.workspaceKey + + f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.local })]) + + const s = f.store.getState() + expect(s.tabsByWorktree[key]?.map((tab) => tab.id)).toEqual([ + f.remote.terminalTabId, + f.dormantTabId + ]) + expect(s.openFiles.map((file) => file.id)).toEqual([f.remote.fileId]) + }) + + it('purges everything when no other host publishes the id', () => { + const f = twin() + const key = f.workspaceKey + keepOnlyRemoteRow(f) + + f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })]) + + const s = f.store.getState() + expect(s.tabsByWorktree[key]).toBeUndefined() + expect(s.openFiles).toEqual([]) + expect(s.gitStatusByWorktree[key]).toBeUndefined() + expect(hasWorktreeSleepIntent(key)).toBe(false) + // Recency stays host-scoped, as before. + expect(Object.keys(s.lastVisitedAtByWorktreeId)).toEqual([ + getWorktreeVisitKey(key, f.local.hostId) + ]) + }) + + it('purges everything when every publishing host is targeted', () => { + const f = twin() + const key = f.workspaceKey + + f.store + .getState() + .purgeWorktreeTerminalState([ + hostWorktreeTarget({ id: key, ...f.remote }), + hostWorktreeTarget({ id: key, ...f.local }) + ]) + + expect(f.store.getState().tabsByWorktree[key]).toBeUndefined() + expect(f.store.getState().gitStatusByWorktree[key]).toBeUndefined() + }) + + it('keeps the raw-id purge reaching both twins', () => { + const f = twin() + const key = f.workspaceKey + + f.store.getState().purgeWorktreeTerminalState([key]) + + const s = f.store.getState() + expect(s.tabsByWorktree[key]).toBeUndefined() + expect(s.openFiles).toEqual([]) + expect(s.lastVisitedAtByWorktreeId).toEqual({}) + }) + + it("drops a tabless sleeping record that names the purged SSH host, not the twin's", () => { + const f = twin() + const key = f.workspaceKey + const record = f.store.getState().sleepingAgentSessionsByPaneKey[f.remote.paneKey] + f.store.setState((s) => ({ + sleepingAgentSessionsByPaneKey: { + ...s.sleepingAgentSessionsByPaneKey, + 'gone-ssh:leaf': { ...record, paneKey: 'gone-ssh:leaf' }, + 'gone-local:leaf': { ...record, paneKey: 'gone-local:leaf', connectionId: null } + } + })) + + f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })]) + + expect(Object.keys(f.store.getState().sleepingAgentSessionsByPaneKey).sort()).toEqual( + ['gone-local:leaf', f.local.paneKey].sort() + ) + }) + + it('selects a surviving tab when the purged terminal was active', () => { + const f = twin() + const key = f.workspaceKey + f.store.setState((s) => ({ + activeTabId: f.remote.terminalTabId, + activeTabType: 'terminal', + groupsByWorktree: { + [key]: (s.groupsByWorktree[key] ?? []).map((group) => ({ + ...group, + activeTabId: f.remote.terminalTabId, + recentTabIds: [f.local.fileId, f.remote.terminalTabId] + })) + } + })) + + f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })]) + + const s = f.store.getState() + expect(s.groupsByWorktree[key]?.[0]?.activeTabId).toBe(f.local.fileId) + expect(s.activeTabType).toBe('editor') + expect(s.activeFileId).toBe(f.local.fileId) + }) + + it("attributes a paired runtime's purge to the SSH copy it reaches", () => { + const f = twin() + const key = f.workspaceKey + const runtimeHostId = `runtime:${TWIN_RUNTIME_ENV}` as const + f.store.setState((s) => ({ + worktreesByRepo: { + [f.projectId]: (s.worktreesByRepo[f.projectId] ?? []).map((row) => + row.hostId === f.remote.hostId + ? { ...row, runtimeOwnerEnvironmentId: TWIN_RUNTIME_ENV } + : row + ) + }, + lastVisitedAtByWorktreeId: { + ...s.lastVisitedAtByWorktreeId, + [getWorktreeVisitKey(key, runtimeHostId)]: 3 + } + })) + + f.store + .getState() + .purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, hostId: runtimeHostId })]) + + const s = f.store.getState() + expect(s.tabsByWorktree[key]?.map((tab) => tab.id)).toEqual([ + f.local.terminalTabId, + f.dormantTabId + ]) + expect(s.openFiles.map((file) => file.id)).toEqual([f.local.fileId]) + // Recency written under either identity of the purged copy goes; the twin's stays. + expect(Object.keys(s.lastVisitedAtByWorktreeId)).toEqual([ + getWorktreeVisitKey(key, f.local.hostId) + ]) + }) + + it('keeps an untouched legacy selection when another host is purged', () => { + const f = twin() + const key = f.workspaceKey + f.store.setState({ + activeWorkspaceExecutionHostId: f.local.hostId, + activeTabId: f.local.terminalTabId, + activeTabType: 'terminal', + activeFileIdByWorktree: { [key]: f.local.fileId }, + // A legacy workspace: no tab groups, so derivation prefers a remembered editor. + groupsByWorktree: {} + }) + + f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })]) + + expect(f.store.getState().activeTabId).toBe(f.local.terminalTabId) + expect(f.store.getState().activeTabType).toBe('terminal') + }) + + it.each([ + ['drops', 'remote-source.md', 'local-source.md', false], + ['keeps', 'shared-source.md', 'shared-source.md', true] + ] as const)( + "%s a purged preview's source state by whether a surviving file still uses it", + (_verb, remoteSource, localSource, kept) => { + const f = twin() + const key = f.workspaceKey + f.store.setState((s) => ({ + openFiles: s.openFiles.map((file) => ({ + ...file, + markdownPreviewSourceFileId: file.id === f.remote.fileId ? remoteSource : localSource + })), + editorDrafts: { ...s.editorDrafts, [remoteSource]: 'source draft' } + })) + + f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })]) + + expect(remoteSource in f.store.getState().editorDrafts).toBe(kept) + expect(f.store.getState().editorDrafts[f.local.fileId]).toBeDefined() + } + ) +}) diff --git a/src/renderer/src/store/repos/repo-removal-host-twin-teardown.ts b/src/renderer/src/store/repos/repo-removal-host-twin-teardown.ts index 218ec7527ef..21df8575f32 100644 --- a/src/renderer/src/store/repos/repo-removal-host-twin-teardown.ts +++ b/src/renderer/src/store/repos/repo-removal-host-twin-teardown.ts @@ -6,7 +6,7 @@ import { buildTerminalTabRetirementPlans } from '../slices/terminal-tab-retireme import { omitSleepingAgentSessionsForWorktrees } from '../slices/worktrees/teardown/sleeping-agent-session-omission' import type { RecentlyClosedTabKind } from '../slices/recently-closed-tabs' import { omitRecordKeys } from '../slices/worktrees/teardown/record-key-omission' -import { cancelStructuredLaunchesForWorktrees } from '../slices/worktrees/teardown/worktree-purge-state' +import { cancelStructuredLaunchesForWorktrees } from '../slices/worktrees/teardown/purged-structured-launch-retirement' import { belongsToRemovedHost, collectRemovedHostTwinState, diff --git a/src/renderer/src/store/slices/tabs/kept-worktree-surface-pruning.ts b/src/renderer/src/store/slices/tabs/kept-worktree-surface-pruning.ts new file mode 100644 index 00000000000..ac6fb832a4b --- /dev/null +++ b/src/renderer/src/store/slices/tabs/kept-worktree-surface-pruning.ts @@ -0,0 +1,127 @@ +import type { AppState } from '../../types' +import { toVisibleTabType, type Tab, type TabGroup } from '../../../../../shared/tab-types' +import { sanitizeRecentTabIds } from '../tab-group-state' + +/** Surfaces leaving workspaces that stay, such as one host's tabs under an id its twin publishes. */ +export type KeptWorktreeSurfaceRemoval = { + worktreeIds: ReadonlySet + terminalTabIds: ReadonlySet + fileIds: ReadonlySet + browserTabIds: ReadonlySet + /** Unified tab ids of non-file surfaces such as chats. */ + unifiedTabIds: ReadonlySet +} + +function isRemovedUnifiedTab(tab: Tab, removed: KeptWorktreeSurfaceRemoval): boolean { + if (removed.unifiedTabIds.has(tab.id)) { + return true + } + if (tab.contentType === 'terminal') { + return removed.terminalTabIds.has(tab.entityId) + } + if (tab.contentType === 'browser') { + return removed.browserTabIds.has(tab.entityId) + } + return toVisibleTabType(tab.contentType) === 'editor' && removed.fileIds.has(tab.entityId) +} + +function pruneGroup(group: TabGroup, removedIds: ReadonlySet): TabGroup { + if (!group.tabOrder.some((id) => removedIds.has(id))) { + return group + } + const tabOrder = group.tabOrder.filter((id) => !removedIds.has(id)) + const recentTabIds = sanitizeRecentTabIds(group.recentTabIds, tabOrder) + // Why MRU first: closing the active tab lands on the previously active one, as closeUnifiedTab does. + const activeTabId = + group.activeTabId && removedIds.has(group.activeTabId) + ? (recentTabIds.at(-1) ?? tabOrder[0] ?? null) + : group.activeTabId + return { + ...group, + tabOrder, + activeTabId, + ...(group.recentTabIds ? { recentTabIds } : {}) + } +} + +/** Rewrites only the kept workspaces' entries, keeping the map's identity when none changes. */ +function pruneEntries( + map: Record, + worktreeIds: ReadonlySet, + prune: (value: T) => T | undefined +): Record { + let next: Record | null = null + for (const worktreeId of worktreeIds) { + if (!(worktreeId in map)) { + continue + } + const value = prune(map[worktreeId]) + if (value === map[worktreeId]) { + continue + } + next ??= { ...map } + if (value === undefined) { + delete next[worktreeId] + } else { + next[worktreeId] = value + } + } + return next ?? map +} + +function withoutIds(items: T[], ids: ReadonlySet): T[] { + return items.some((item) => ids.has(item.id)) ? items.filter((item) => !ids.has(item.id)) : items +} + +/** Per-field pruners for workspaces that stay while some of their surfaces leave. */ +export function createKeptWorktreeSurfacePruners( + s: Pick, + removed: KeptWorktreeSurfaceRemoval +) { + const { worktreeIds } = removed + const removedUnifiedIds = new Set( + [...worktreeIds].flatMap((worktreeId) => + (s.unifiedTabsByWorktree[worktreeId] ?? []) + .filter((tab) => isRemovedUnifiedTab(tab, removed)) + .map((tab) => tab.id) + ) + ) + const removedIds = new Set([ + ...removedUnifiedIds, + ...removed.terminalTabIds, + ...removed.fileIds, + ...removed.browserTabIds + ]) + // Why dropped, not repointed: selection derivation falls back to a surviving surface. + const dropSelectionOf = + (ids: ReadonlySet) => + (map: Record): Record => + pruneEntries(map, worktreeIds, (value) => + typeof value === 'string' && ids.has(value) ? undefined : value + ) + return { + tabs: (map: AppState['tabsByWorktree']) => + pruneEntries(map, worktreeIds, (tabs) => withoutIds(tabs, removed.terminalTabIds)), + unifiedTabs: (map: AppState['unifiedTabsByWorktree']) => + pruneEntries(map, worktreeIds, (tabs) => withoutIds(tabs, removedUnifiedIds)), + groups: (map: AppState['groupsByWorktree']) => + pruneEntries(map, worktreeIds, (groups) => { + const next = groups.map((group) => pruneGroup(group, removedIds)) + return next.every((group, index) => group === groups[index]) ? groups : next + }), + tabBarOrder: (map: AppState['tabBarOrderByWorktree']) => + pruneEntries(map, worktreeIds, (order) => + order.some((id) => removedIds.has(id)) ? order.filter((id) => !removedIds.has(id)) : order + ), + browserTabs: (map: AppState['browserTabsByWorktree']) => + pruneEntries(map, worktreeIds, (tabs) => withoutIds(tabs, removed.browserTabIds)), + files: (files: AppState['openFiles']) => withoutIds(files, removed.fileIds), + activeTabIds: dropSelectionOf( + removed.terminalTabIds + ), + activeFileIds: dropSelectionOf(removed.fileIds), + activeBrowserTabIds: dropSelectionOf( + removed.browserTabIds + ) + } +} diff --git a/src/renderer/src/store/slices/tabs/worktree-layout-lifecycle.ts b/src/renderer/src/store/slices/tabs/worktree-layout-lifecycle.ts index 6e47fddb79c..856ae735d2b 100644 --- a/src/renderer/src/store/slices/tabs/worktree-layout-lifecycle.ts +++ b/src/renderer/src/store/slices/tabs/worktree-layout-lifecycle.ts @@ -3,6 +3,11 @@ import type { BrowserWorkspace } from '../../../../../shared/browser-workspace-t import type { TerminalTab } from '../../../../../shared/terminal-tab-types' import { omitRecordKeys } from '../worktrees/teardown/record-key-omission' import { getTerminalActivationSpawnSuppression } from '../terminal-activation-spawn-suppression' +import { collapseGroupLayout } from '../../../../../shared/workspace-layout/tab-group-layout-tree' +import { + createKeptWorktreeSurfacePruners, + type KeptWorktreeSurfaceRemoval +} from './kept-worktree-surface-pruning' // Layout transitions that worktree, repo and SSH lifecycle code folds into its own store update. // Pure patches, not actions: each caller's update is one atomic set that also clears non-layout state. @@ -29,39 +34,108 @@ type WorktreeLayoutPatch = Pick< | 'activeTabType' > -/** Drops removed worktrees' tabs, groups and pane layouts, and clears selection that pointed into them. */ +/** + * Splits a kept workspace's purge emptied, collapsed the way closing their last tab collapses + * them, so the surviving twin's group takes the space and the selection. + */ +function collapseEmptiedKeptGroups( + s: Pick, + groupsByWorktree: AppState['groupsByWorktree'], + worktreeIds: ReadonlySet +): { + groups: AppState['groupsByWorktree'] + layout: AppState['layoutByWorktree'] + activeGroups: AppState['activeGroupIdByWorktree'] +} { + let groupsOut = groupsByWorktree + let layoutOut = s.layoutByWorktree + let activeOut = s.activeGroupIdByWorktree + for (const worktreeId of worktreeIds) { + const before = s.groupsByWorktree[worktreeId] ?? [] + let groups = groupsOut[worktreeId] ?? [] + const emptied = groups.filter( + (group) => + group.tabOrder.length === 0 && + (before.find((prior) => prior.id === group.id)?.tabOrder.length ?? 0) > 0 + ) + for (const group of emptied) { + if (groups.length <= 1 || !layoutOut[worktreeId]) { + break + } + groups = groups.filter((candidate) => candidate.id !== group.id) + const collapsed = collapseGroupLayout( + layoutOut, + activeOut, + worktreeId, + group.id, + groups[0]?.id ?? null + ) + layoutOut = collapsed.layoutByWorktree + // Why: only the collapsed group's own focus moves; an untouched active group keeps it. + const activeGroupId = activeOut[worktreeId] + activeOut = + activeGroupId === undefined || activeGroupId === group.id + ? collapsed.activeGroupIdByWorktree + : { ...collapsed.activeGroupIdByWorktree, [worktreeId]: activeGroupId } + } + if (groups !== (groupsOut[worktreeId] ?? [])) { + groupsOut = { ...groupsOut, [worktreeId]: groups } + } + } + return { groups: groupsOut, layout: layoutOut, activeGroups: activeOut } +} + +/** + * Drops removed worktrees' tabs, groups and pane layouts, and clears selection that pointed into + * them. `kept` names surfaces leaving workspaces that stay, whose entries are pruned instead. + */ export function removeWorktreeLayout( s: AppState, removed: { worktreeIds: ReadonlySet tabIds: ReadonlySet activeFileCleared: boolean + kept?: KeptWorktreeSurfaceRemoval } ): WorktreeLayoutPatch { const { worktreeIds, tabIds, activeFileCleared } = removed const omitByWorktree = (m: Record | undefined) => omitRecordKeys(m, worktreeIds) const omitByTabId = (m: Record | undefined) => omitRecordKeys(m, tabIds) const removedActiveWorktree = s.activeWorktreeId != null && worktreeIds.has(s.activeWorktreeId) + const kept = removed.kept ? createKeptWorktreeSurfacePruners(s, removed.kept) : null + const keep = (value: T, prune: ((value: T) => T) | undefined): T => + prune ? prune(value) : value + const groupLayout = removed.kept + ? collapseEmptiedKeptGroups( + s, + keep(omitByWorktree(s.groupsByWorktree), kept?.groups), + removed.kept.worktreeIds + ) + : null + const remainingFiles = s.openFiles.some((f) => worktreeIds.has(f.worktreeId)) + ? s.openFiles.filter((f) => !worktreeIds.has(f.worktreeId)) + : s.openFiles return { - tabsByWorktree: omitByWorktree(s.tabsByWorktree), + tabsByWorktree: keep(omitByWorktree(s.tabsByWorktree), kept?.tabs), terminalLayoutsByTabId: omitByTabId(s.terminalLayoutsByTabId), ptyIdsByTabId: omitByTabId(s.ptyIdsByTabId), // Why: split-tab layout/group state is worktree-owned; leaving it makes a deleted worktree look restorable. - unifiedTabsByWorktree: omitByWorktree(s.unifiedTabsByWorktree), - groupsByWorktree: omitByWorktree(s.groupsByWorktree), - layoutByWorktree: omitByWorktree(s.layoutByWorktree), - activeGroupIdByWorktree: omitByWorktree(s.activeGroupIdByWorktree), + unifiedTabsByWorktree: keep(omitByWorktree(s.unifiedTabsByWorktree), kept?.unifiedTabs), + groupsByWorktree: groupLayout?.groups ?? omitByWorktree(s.groupsByWorktree), + layoutByWorktree: omitByWorktree(groupLayout?.layout ?? s.layoutByWorktree), + activeGroupIdByWorktree: omitByWorktree(groupLayout?.activeGroups ?? s.activeGroupIdByWorktree), // Why: the tab strip persists visual order per worktree; drop the entry so stale tab IDs aren't retained. - tabBarOrderByWorktree: omitByWorktree(s.tabBarOrderByWorktree), - browserTabsByWorktree: omitByWorktree(s.browserTabsByWorktree), + tabBarOrderByWorktree: keep(omitByWorktree(s.tabBarOrderByWorktree), kept?.tabBarOrder), + browserTabsByWorktree: keep(omitByWorktree(s.browserTabsByWorktree), kept?.browserTabs), // Why guarded: a removed worktree usually has no open file, and an unconditional // filter would hand openFiles a new identity anyway. - openFiles: s.openFiles.some((f) => worktreeIds.has(f.worktreeId)) - ? s.openFiles.filter((f) => !worktreeIds.has(f.worktreeId)) - : s.openFiles, - activeTabIdByWorktree: omitByWorktree(s.activeTabIdByWorktree), - activeFileIdByWorktree: omitByWorktree(s.activeFileIdByWorktree), - activeBrowserTabIdByWorktree: omitByWorktree(s.activeBrowserTabIdByWorktree), + openFiles: keep(remainingFiles, kept?.files), + activeTabIdByWorktree: keep(omitByWorktree(s.activeTabIdByWorktree), kept?.activeTabIds), + activeFileIdByWorktree: keep(omitByWorktree(s.activeFileIdByWorktree), kept?.activeFileIds), + activeBrowserTabIdByWorktree: keep( + omitByWorktree(s.activeBrowserTabIdByWorktree), + kept?.activeBrowserTabIds + ), activeTabTypeByWorktree: omitByWorktree(s.activeTabTypeByWorktree), activeTabId: s.activeTabId && tabIds.has(s.activeTabId) ? null : s.activeTabId, activeFileId: activeFileCleared ? null : s.activeFileId, diff --git a/src/renderer/src/store/slices/terminal-tab-retirement.ts b/src/renderer/src/store/slices/terminal-tab-retirement.ts index 32bc223c317..64e4a8eb75a 100644 --- a/src/renderer/src/store/slices/terminal-tab-retirement.ts +++ b/src/renderer/src/store/slices/terminal-tab-retirement.ts @@ -1,9 +1,11 @@ -import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' +import { + isRemoteRuntimePtyId, + parseRemoteRuntimePtyId +} from '../../../../shared/remote-runtime-pty-id' import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import type { AppState } from '../types' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-state' -import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { resolveTerminalHostOwnership } from '@/lib/terminal-worktree-route' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { isEphemeralSetupTerminalWorktreeId } from '../../../../shared/ephemeral-setup-terminal-worktree-id' diff --git a/src/renderer/src/store/slices/worktree-purge-target.ts b/src/renderer/src/store/slices/worktree-purge-target.ts index 031765acccf..a5a3885382a 100644 --- a/src/renderer/src/store/slices/worktree-purge-target.ts +++ b/src/renderer/src/store/slices/worktree-purge-target.ts @@ -1,7 +1,19 @@ +import type { HostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity' import type { Worktree } from '../../../../shared/worktree/types' -/** A purge target with optional host ownership for host-scoped state. */ +/** A purge target after normalization: a raw id, or one host's copy of it. */ export type WorktreePurgeTarget = Pick -/** Strings remain supported for callers that only have a raw worktree id. */ -export type WorktreePurgeTargets = readonly (string | WorktreePurgeTarget)[] +/** + * A raw id purges every bucket under it. A host target purges only that host's attributable + * state while another host still publishes the id, and everything otherwise. + */ +export type WorktreePurgeTargets = readonly (string | HostWorktreeTarget)[] + +export function normalizeWorktreePurgeTarget( + target: string | HostWorktreeTarget +): WorktreePurgeTarget { + return typeof target === 'string' + ? { id: target } + : { id: target.worktreeId, hostId: target.hostId } +} diff --git a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts index 1a47f7e3702..a06f7fb7730 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts @@ -26,6 +26,7 @@ import { type WorktreeListingMergeOutcome } from './detected-worktree-refresh-admission' import { buildWorktreePurgeState } from '../teardown/worktree-purge-state' +import { hostWorktreeTarget } from '../../../../../../shared/worktree/host-qualified-identity' import { isDisplayNamePersistencePending } from '../metadata/worktree-meta-persist' import { branchName } from '@/lib/git-utils' import { @@ -271,7 +272,7 @@ export function mergeFetchedWorktrees( ...(removedIds.length > 0 ? buildWorktreePurgeState( s, - removedIds.map((id) => ({ id, hostId: args.hostId })) + removedIds.map((id) => hostWorktreeTarget({ id, hostId: args.hostId })) ) : {}) } diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts index 58822da2e01..7cf32e592fb 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts @@ -145,7 +145,7 @@ export function createPurgeWorktreeTerminalState( ): WorktreeSlice['purgeWorktreeTerminalState'] { return (worktreeTargets) => { const purgeableWorktreeTargets = worktreeTargets.filter((target) => { - const worktreeId = typeof target === 'string' ? target : target.id + const worktreeId = typeof target === 'string' ? target : target.worktreeId return worktreeId !== FLOATING_TERMINAL_WORKTREE_ID }) if (purgeableWorktreeTargets.length === 0) { diff --git a/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts b/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts new file mode 100644 index 00000000000..a544ae64580 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts @@ -0,0 +1,250 @@ +import type { AppState } from '../../../types' +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope' +import { getRepoIdFromWorktreeId } from '../../../../../../shared/worktree/id' +import { + composeWorktreeHostIdentity, + type HostScopedWorktreeKey +} from '../../../../../../shared/worktree/host-qualified-identity' +import type { WorktreePurgeTarget } from '../../worktree-purge-target' +import { getFolderWorkspaceHostId } from '../../../folder-workspaces/folder-workspace-catalog' +import { getProjectGroupHostId } from '../../project-group-owner-routing' +import { + collectRemovedHostTwinState, + removedHostCanonicalizer +} from '../../../repos/repo-removal-host-twin-attribution' +import { twinSelectionPatch } from '../../../repos/repo-removal-host-twin-teardown' +import { removeWorktreeLayout } from '../../tabs/worktree-layout-lifecycle' +import { buildActiveSurfacePatch } from '../../tabs/tabs-surface' +import { worktreeHostMatchOptions, worktreeMatchesHost } from '../listing/worktree-host-ownership' +import { collectSurfacePurgeDoomedIds } from './worktree-purge-doomed-ids' +import { collectPtyIdsForTab } from '../../terminal-tab-retirement' +import { createWorktreePurgeOmitters } from './worktree-purge-omitters' +import { omitSleepingAgentSessionsForWorktrees } from './sleeping-agent-session-omission' +import { + cancelStructuredLaunchesForWorktrees, + retirePurgedTabLaunches +} from './purged-structured-launch-retirement' +import { buildWorktreePurgeSurfacePatch } from './worktree-purge-surface-patch' + +type PublisherRow = { + id: string + hostId?: ExecutionHostId + runtimeOwnerEnvironmentId?: string +} + +/** + * For one targeted id: hosts outside the purge that still publish it, and the host each purged + * target's state is attributed to. Rows match by the listing's host matching, so an unstamped + * legacy row or a paired runtime's SSH row counts as the purged copy, not a twin. + */ +function resolvePurgedPublishers( + s: AppState, + workspaceId: string, + purgedHosts: ReadonlySet +): { survivors: ExecutionHostId[]; attributedHostIds: Map } { + const scope = parseWorkspaceKey(workspaceId) + const attributedHostIds = new Map([...purgedHosts].map((hostId) => [hostId, hostId])) + if (scope?.type === 'folder') { + const groups = s.projectGroups ?? [] + const hosts = (s.folderWorkspaces ?? []) + .filter((folder) => folder.id === scope.folderWorkspaceId) + .flatMap((folder) => { + const groupHosts = groups + .filter((group) => group.id === folder.projectGroupId) + .map(getProjectGroupHostId) + // Why every group host: an unstamped folder under a group two hosts publish may be either's. + return folder.executionHostId || folder.connectionId || groupHosts.length === 0 + ? [getFolderWorkspaceHostId(folder, groups)] + : groupHosts + }) + return { + survivors: [...new Set(hosts)].filter((hostId) => !purgedHosts.has(hostId)), + attributedHostIds + } + } + const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceId + const repoId = getRepoIdFromWorktreeId(worktreeId) + const rows: PublisherRow[] = [ + ...(s.worktreesByRepo[repoId] ?? []), + ...(s.detectedWorktreesByRepo[repoId]?.worktrees ?? []) + ].filter((row) => row.id === worktreeId) + const survivors = new Set() + for (const row of rows) { + const purgedHost = [...purgedHosts].find((hostId) => + worktreeMatchesHost(row, hostId, worktreeHostMatchOptions(s, repoId, hostId)) + ) + if (purgedHost) { + // Why: a copy reached through a paired runtime stamps its tabs with its own SSH host. + if (row.hostId && !purgedHosts.has(row.hostId)) { + attributedHostIds.set(purgedHost, row.hostId) + } + continue + } + survivors.add( + row.hostId ?? + (row.runtimeOwnerEnvironmentId + ? toRuntimeExecutionHostId(row.runtimeOwnerEnvironmentId) + : LOCAL_EXECUTION_HOST_ID) + ) + } + return { + survivors: [...survivors].filter((hostId) => !purgedHosts.has(hostId)), + attributedHostIds + } +} + +export type HostTwinPurgeScopes = { + /** Host identities of every host target whose id a twin still publishes. */ + twinTargetKeys: Set + /** Per attributed host, its twin ids with the hosts that still publish them. */ + byHost: Map> +} + +/** Host targets whose id a host outside the purge still publishes. A raw id for the same id wins. */ +export function collectHostTwinPurgeScopes( + s: AppState, + targets: readonly WorktreePurgeTarget[] +): HostTwinPurgeScopes { + const rawIds = new Set(targets.filter((target) => !target.hostId).map((target) => target.id)) + const purgedHostsById = new Map>() + for (const { id, hostId } of targets) { + if (hostId && !rawIds.has(id)) { + purgedHostsById.set(id, (purgedHostsById.get(id) ?? new Set()).add(hostId)) + } + } + const scopes: HostTwinPurgeScopes = { twinTargetKeys: new Set(), byHost: new Map() } + for (const [id, purgedHosts] of purgedHostsById) { + const { survivors, attributedHostIds } = resolvePurgedPublishers(s, id, purgedHosts) + if (survivors.length === 0) { + continue + } + for (const hostId of purgedHosts) { + scopes.twinTargetKeys.add(composeWorktreeHostIdentity(hostId, id)) + const attributedHostId = attributedHostIds.get(hostId) ?? hostId + const scope = scopes.byHost.get(attributedHostId) ?? new Map() + scopes.byHost.set(attributedHostId, scope.set(id, survivors)) + } + } + return scopes +} + +/** + * Purges one host's attributable surfaces under ids a twin still publishes. Worktree-keyed + * buckets, raw-id module stores and surfaces with no host evidence stay: they may be the twin's. + */ +export function buildHostTwinPurgeState( + s: AppState, + hostId: ExecutionHostId, + twinHostIds: ReadonlyMap +): Partial { + const twinIds = new Set(twinHostIds.keys()) + const removed = collectRemovedHostTwinState(s, twinIds, hostId, twinHostIds) + const canonical = (worktreeId: string) => + removedHostCanonicalizer(s, worktreeId, hostId, twinHostIds.get(worktreeId) ?? []) + const cancelledSessionIds = cancelStructuredLaunchesForWorktrees( + twinIds, + (worktreeId, launchHostId) => canonical(worktreeId)(launchHostId) === hostId + ) + const terminalTabs: { id: string; ptyId: string | null }[] = [] + for (const worktreeId of twinIds) { + const legacyTabs = (s.tabsByWorktree[worktreeId] ?? []).filter((tab) => + removed.terminalTabIds.has(tab.id) + ) + terminalTabs.push(...legacyTabs) + retirePurgedTabLaunches( + s, + worktreeId, + legacyTabs, + (s.unifiedTabsByWorktree[worktreeId] ?? []).filter((tab) => removed.otherTabIds.has(tab.id)), + cancelledSessionIds + ) + } + // Why: a hydrated terminal can exist only as a unified tab, with no legacy row. + for (const tabId of removed.terminalTabIds) { + if (!terminalTabs.some((tab) => tab.id === tabId)) { + terminalTabs.push({ id: tabId, ptyId: null }) + } + } + // Why: a preview keys its source's editor state, unless a surviving file still uses that source. + const survivingFiles = s.openFiles.filter((file) => !removed.fileIds.has(file.id)) + const previewSourceIds = s.openFiles.flatMap((file) => + removed.fileIds.has(file.id) && + file.markdownPreviewSourceFileId && + !survivingFiles.some( + (other) => + other.id === file.markdownPreviewSourceFileId || + other.markdownPreviewSourceFileId === file.markdownPreviewSourceFileId + ) + ? [file.markdownPreviewSourceFileId] + : [] + ) + const doomed = collectSurfacePurgeDoomedIds(s, { + tabs: terminalTabs, + browserWorkspaceIds: removed.browserTabIds, + fileIds: [...removed.fileIds, ...previewSourceIds] + }) + // Why: a PTY a kept tab still binds stays live, so its PTY-keyed state must stay with it. + for (const worktreeId of twinIds) { + for (const tab of s.tabsByWorktree[worktreeId] ?? []) { + if (!doomed.doomedTabIds.has(tab.id)) { + for (const ptyId of collectPtyIdsForTab(s, tab.id, tab.ptyId)) { + doomed.doomedPtyIds.delete(ptyId) + } + } + } + } + const omitters = createWorktreePurgeOmitters(s, new Set(), doomed) + const tabPrefixedSleeping = { + sleepingAgentSessionsByPaneKey: omitters.omitByPaneKeyTabPrefix( + s.sleepingAgentSessionsByPaneKey + ), + agentLaunchConfigByPaneKey: omitters.omitByPaneKeyTabPrefix(s.agentLaunchConfigByPaneKey) + } + // Why SSH only: a record whose tab is gone names its host just by connection; null is ambiguous. + const sshRecordPaneKeys = Object.entries(s.sleepingAgentSessionsByPaneKey ?? {}).flatMap( + ([paneKey, record]) => + twinIds.has(record.worktreeId) && + record.connectionId && + canonical(record.worktreeId)(toSshExecutionHostId(record.connectionId)) === hostId + ? [paneKey] + : [] + ) + const layout = removeWorktreeLayout(s, { + worktreeIds: new Set(), + tabIds: doomed.doomedTabIds, + activeFileCleared: s.activeFileId != null && removed.fileIds.has(s.activeFileId), + kept: { + worktreeIds: twinIds, + terminalTabIds: removed.terminalTabIds, + fileIds: removed.fileIds, + browserTabIds: removed.browserTabIds, + unifiedTabIds: removed.otherTabIds + } + }) + const active = s.activeWorktreeId && twinIds.has(s.activeWorktreeId) ? s.activeWorktreeId : null + // Why only then: re-deriving an untouched selection could switch the visible surface. + const selectionRemoved = + active !== null && + ((s.activeTabId != null && removed.terminalTabIds.has(s.activeTabId)) || + (s.activeFileId != null && removed.fileIds.has(s.activeFileId)) || + (s.activeBrowserTabId != null && removed.browserTabIds.has(s.activeBrowserTabId)) || + (layout.groupsByWorktree[active] ?? []).some( + (group, index) => group.activeTabId !== s.groupsByWorktree[active]?.[index]?.activeTabId + )) + return { + ...layout, + // Why: a group may now show a surviving tab of another kind, so selection is derived again. + ...(selectionRemoved ? buildActiveSurfacePatch({ ...s, ...layout }, active) : {}), + ...buildWorktreePurgeSurfacePatch(s, omitters, doomed.doomedTabIds, { + ...tabPrefixedSleeping, + ...omitSleepingAgentSessionsForWorktrees(tabPrefixedSleeping, new Set(), sshRecordPaneKeys) + }), + ...twinSelectionPatch(s, twinHostIds, hostId) + } +} diff --git a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts index 2e2d58c4765..bdd36325980 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts @@ -15,7 +15,7 @@ import { dropWorktreeRowsForRemovedRuntimeEnvironments, isRemovedRuntimeHostId } from '../../stale-runtime-host-rows' -import { buildWorktreePurgeState } from './worktree-purge-state' +import { buildFullWorktreePurgeState } from './worktree-purge-state' import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency' import { settleAttentionRemovedBy } from '../../workspace-attention/attention-removal-follow-up' @@ -211,7 +211,9 @@ export function createPurgeStaleRuntimeHostState( const targets = removedWorktreeTargets.filter((target) => target.id === worktreeId) return targets.length > 0 ? targets : [{ id: worktreeId }] }) - const purgeState = purgeTargets.length > 0 ? buildWorktreePurgeState(s, purgeTargets) : {} + // Why full: survivor ids were dropped above unless the session partition proves the bare-id state is the removed host's. + const purgeState = + purgeTargets.length > 0 ? buildFullWorktreePurgeState(s, purgeTargets) : {} const visitPurgeTargets = [ ...removedWorktreeTargets, ...purgeTargets.filter( diff --git a/src/renderer/src/store/slices/worktrees/teardown/purged-structured-launch-retirement.ts b/src/renderer/src/store/slices/worktrees/teardown/purged-structured-launch-retirement.ts new file mode 100644 index 00000000000..99927b646bb --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/teardown/purged-structured-launch-retirement.ts @@ -0,0 +1,89 @@ +import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import type { TerminalTab } from '../../../../../../shared/terminal-tab-types' +import type { Tab } from '../../../../../../shared/tab-types' +import type { AppState } from '../../../types' +import { settleAgentLaunchPaneStart } from '@/lib/agent-launch-pane-start' +import { + getStructuredAgentSessionLaunchOwner, + markStructuredAgentSessionLaunchCancelledSilently, + shouldRetainStructuredAgentSessionLaunchTab, + structuredLaunchStates +} from '@/lib/structured-agent-session-launch-registry' +import { discardStructuredAgentSessionChatSends } from '@/lib/structured-agent-session-launch-prompt' +import { retireStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner-registry' +import { clearWebSessionFocusIntentIfMatches } from '@/runtime/web-session-focus-intent' +import { + structuredAgentSessionFocusOwner, + structuredAgentSessionTargetForTab, + structuredAgentSessionTargetForHost +} from '@/runtime/structured-agent-session-owner' + +/** Settles launch waits and retires chat ownership for purged tabs of one workspace. */ +export function retirePurgedTabLaunches( + s: AppState, + worktreeId: string, + terminalTabs: readonly TerminalTab[], + unifiedTabs: readonly Tab[], + cancelledSessionIds: ReadonlySet +): void { + // A launch tab that never spawned has no spawn left to answer its prompt's wait. + for (const tab of terminalTabs) { + if (tab.agentLaunchPane) { + settleAgentLaunchPaneStart(tab.id, tab.agentLaunchPane.leafId, null) + } + } + for (const tab of unifiedTabs) { + if (tab.contentType === 'agent-session') { + const target = structuredAgentSessionTargetForTab(s, tab) + if (target) { + retireStructuredAgentSessionReadOwner(tab.entityId, target) + } + } + // A retained launch here survived a reload, so its persisted record names its host. + const owner = + tab.contentType === 'agent-session' && !cancelledSessionIds.has(tab.entityId) + ? getStructuredAgentSessionLaunchOwner(tab.entityId) + : undefined + const target = structuredAgentSessionTargetForHost(owner) + if (owner && target && shouldRetainStructuredAgentSessionLaunchTab(worktreeId, tab.entityId)) { + markStructuredAgentSessionLaunchCancelledSilently(worktreeId, tab.entityId, owner) + discardStructuredAgentSessionChatSends(tab.entityId) + clearWebSessionFocusIntentIfMatches( + structuredAgentSessionFocusOwner(target), + worktreeId, + `agent-session:${tab.entityId}` + ) + } + } +} + +/** Cancels provisional structured-chat launches in these worktrees, on the given host only. */ +export function cancelStructuredLaunchesForWorktrees( + worktreeIdSet: ReadonlySet, + ownedByHost?: (worktreeId: string, executionHostId: ExecutionHostId) => boolean +): Set { + const cancelledSessionIds = new Set() + for (const launch of structuredLaunchStates()) { + const worktreeId = launch.intent.worktreeId + if ( + worktreeIdSet.has(worktreeId) && + (!ownedByHost || ownedByHost(worktreeId, launch.intent.executionHostId)) && + shouldRetainStructuredAgentSessionLaunchTab(worktreeId, launch.intent.sessionId) + ) { + markStructuredAgentSessionLaunchCancelledSilently( + worktreeId, + launch.intent.sessionId, + launch.intent.executionHostId + ) + discardStructuredAgentSessionChatSends(launch.intent.sessionId) + retireStructuredAgentSessionReadOwner(launch.intent.sessionId, launch.intent.target) + clearWebSessionFocusIntentIfMatches( + structuredAgentSessionFocusOwner(launch.intent.target), + worktreeId, + `agent-session:${launch.intent.sessionId}` + ) + cancelledSessionIds.add(launch.intent.sessionId) + } + } + return cancelledSessionIds +} diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-doomed-ids.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-doomed-ids.ts index 1b0110c3b92..f77eadbf6c3 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-doomed-ids.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-doomed-ids.ts @@ -16,6 +16,37 @@ export type WorktreePurgeDoomedIds = { export function collectWorktreePurgeDoomedIds( s: AppState, worktreeIdSet: Set +): WorktreePurgeDoomedIds { + const tabs: { id: string; ptyId: string | null }[] = [] + const browserWorkspaceIds: string[] = [] + const fileIds: string[] = [] + for (const id of worktreeIdSet) { + tabs.push(...(s.tabsByWorktree[id] ?? [])) + for (const workspace of s.browserTabsByWorktree[id] ?? []) { + browserWorkspaceIds.push(workspace.id) + } + // Why: drop the auto-derived detached-HEAD display name so the module-level map doesn't retain removed worktrees for the session. + forgetDetachedHeadAutoDerivedDisplayName(id) + } + for (const file of s.openFiles) { + if (worktreeIdSet.has(file.worktreeId)) { + fileIds.push(file.id) + if (file.markdownPreviewSourceFileId) { + fileIds.push(file.markdownPreviewSourceFileId) + } + } + } + return collectSurfacePurgeDoomedIds(s, { tabs, browserWorkspaceIds, fileIds }) +} + +/** Doomed ids for named surfaces, which may sit in a workspace that stays (a surviving twin's). */ +export function collectSurfacePurgeDoomedIds( + s: AppState, + surfaces: { + tabs: readonly { id: string; ptyId: string | null }[] + browserWorkspaceIds: Iterable + fileIds: Iterable + } ): WorktreePurgeDoomedIds { // Collect every tab id (and removed file id) we are about to orphan. const doomedTabIds = new Set() @@ -37,22 +68,15 @@ export function collectWorktreePurgeDoomedIds( addDoomedPtyId(ptyId) } } - const doomedBrowserWorkspaceIds = new Set() + const doomedBrowserWorkspaceIds = new Set(surfaces.browserWorkspaceIds) const doomedPageIds = new Set() - const removedFileIds = new Set() - for (const id of worktreeIdSet) { - for (const tab of s.tabsByWorktree[id] ?? []) { - doomedTabIds.add(tab.id) - // Null-tolerant like the omit* helpers below: some callers pass partial state omitting this slice (production store always inits to {}). - addDoomedTabPtyIds(tab.id, tab.ptyId) - // Why: a removed worktree's panes are gone, so drop their hibernation output epochs from the module-level map (a future pane mints a fresh leafId at epoch 0). - forgetAgentHibernationTabOutput(tab.id) - } - for (const workspace of s.browserTabsByWorktree[id] ?? []) { - doomedBrowserWorkspaceIds.add(workspace.id) - } - // Why: drop the auto-derived detached-HEAD display name so the module-level map doesn't retain removed worktrees for the session. - forgetDetachedHeadAutoDerivedDisplayName(id) + const removedFileIds = new Set(surfaces.fileIds) + for (const tab of surfaces.tabs) { + doomedTabIds.add(tab.id) + // Null-tolerant like the omit* helpers below: some callers pass partial state omitting this slice (production store always inits to {}). + addDoomedTabPtyIds(tab.id, tab.ptyId) + // Why: a removed worktree's panes are gone, so drop their hibernation output epochs from the module-level map (a future pane mints a fresh leafId at epoch 0). + forgetAgentHibernationTabOutput(tab.id) } // Why: same rationale for doomed tabs' foreground last-seen timestamps and agent-startup delivery guards — retired tab ids never recur. forgetForegroundTerminalTabs(doomedTabIds) @@ -66,14 +90,6 @@ export function collectWorktreePurgeDoomedIds( doomedPageIds.add(page.id) } } - for (const file of s.openFiles) { - if (worktreeIdSet.has(file.worktreeId)) { - removedFileIds.add(file.id) - if (file.markdownPreviewSourceFileId) { - removedFileIds.add(file.markdownPreviewSourceFileId) - } - } - } return { doomedTabIds, doomedPtyIds, diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index 18a51d1443e..87370733f1c 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -1,6 +1,6 @@ -import type { ExecutionHostId } from '../../../../../../shared/execution-host' import type { AppState } from '../../../types' import type { WorktreePurgeTarget, WorktreePurgeTargets } from '../../worktree-helpers' +import { normalizeWorktreePurgeTarget } from '../../worktree-purge-target' import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-control-huge-repo-warning-dismissals' import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope' import { pruneHostedReviewLinkMutationGenerations } from '../metadata/hosted-review-link-mutation' @@ -8,72 +8,79 @@ import { collectWorktreePurgeDoomedIds } from './worktree-purge-doomed-ids' import { createWorktreePurgeOmitters } from './worktree-purge-omitters' import { omitSleepingAgentSessionsForWorktrees } from './sleeping-agent-session-omission' import { removeDeleteStatesForWorktreeIds } from './worktree-delete-state' +import { omitRecordKeys } from './record-key-omission' import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency' import { forgetAmbiguousOwnerWarnings } from '../listing/worktree-owner-settings' import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' -import { settleAgentLaunchPaneStart } from '@/lib/agent-launch-pane-start' -import { - getStructuredAgentSessionLaunchOwner, - markStructuredAgentSessionLaunchCancelledSilently, - shouldRetainStructuredAgentSessionLaunchTab, - structuredLaunchStates -} from '@/lib/structured-agent-session-launch-registry' -import { discardStructuredAgentSessionChatSends } from '@/lib/structured-agent-session-launch-prompt' -import { retireStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner-registry' -import { clearWebSessionFocusIntentIfMatches } from '@/runtime/web-session-focus-intent' -import { - structuredAgentSessionFocusOwner, - structuredAgentSessionTargetForTab, - structuredAgentSessionTargetForHost -} from '@/runtime/structured-agent-session-owner' -import { removeTabsAttention } from '../../workspace-attention/subject-attention-transitions' import { removeWorktreeLayout } from '../../tabs/worktree-layout-lifecycle' +import { + cancelStructuredLaunchesForWorktrees, + retirePurgedTabLaunches +} from './purged-structured-launch-retirement' +import { buildWorktreePurgeSurfacePatch } from './worktree-purge-surface-patch' +import { buildHostTwinPurgeState, collectHostTwinPurgeScopes } from './host-twin-worktree-purge' +import { composeWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity' -/** Builds a bulk cleanup patch and clears auxiliary warning records without requiring individual terminal teardown. */ +/** + * Builds a bulk cleanup patch and clears auxiliary warning records without requiring individual + * terminal teardown. A host target whose id another host still publishes purges only that host's + * attributable state; everything else purges every bucket under the id. + */ export function buildWorktreePurgeState( s: AppState, worktreeTargets: WorktreePurgeTargets ): Partial { - const normalizedTargets: WorktreePurgeTarget[] = worktreeTargets.map((target) => - typeof target === 'string' ? { id: target } : target + const targets = worktreeTargets.map(normalizeWorktreePurgeTarget) + const twinScopes = collectHostTwinPurgeScopes(s, targets) + if (twinScopes.byHost.size === 0) { + return buildFullWorktreePurgeState(s, targets) + } + const fullTargets = targets.filter( + (target) => + !target.hostId || + !twinScopes.twinTargetKeys.has(composeWorktreeHostIdentity(target.hostId, target.id)) ) + let patch = fullTargets.length > 0 ? buildFullWorktreePurgeState(s, fullTargets) : {} + for (const [hostId, twinHostIds] of twinScopes.byHost) { + patch = { ...patch, ...buildHostTwinPurgeState({ ...s, ...patch }, hostId, twinHostIds) } + } + // Why both hosts: a paired runtime's copy writes recency and delete state under either identity. + const hostKeys = [ + ...targets.filter((target) => !fullTargets.includes(target)), + ...[...twinScopes.byHost].flatMap(([hostId, twins]) => + [...twins.keys()].map((id) => ({ id, hostId })) + ) + ].flatMap((target) => (target.hostId ? [{ id: target.id, hostId: target.hostId }] : [])) + const next = { ...s, ...patch } + return { + ...patch, + lastVisitedAtByWorktreeId: removeWorktreeVisitEntriesForTargets( + next.lastVisitedAtByWorktreeId, + hostKeys + ), + deleteStateByWorktreeId: omitRecordKeys( + next.deleteStateByWorktreeId, + hostKeys.map((target) => composeWorktreeHostIdentity(target.hostId, target.id)) + ) + } +} + +/** Drops every bucket under the targets' ids; visit recency alone stays host-scoped. */ +export function buildFullWorktreePurgeState( + s: AppState, + normalizedTargets: readonly WorktreePurgeTarget[] +): Partial { const worktreeIdSet = new Set(normalizedTargets.map((target) => target.id)) // Why: bulk purges bypass close actions, so provisional ownership must die before tab state does. const cancelledSessionIds = cancelStructuredLaunchesForWorktrees(worktreeIdSet) for (const worktreeId of worktreeIdSet) { - // A launch tab that never spawned has no spawn left to answer its prompt's wait. - for (const tab of s.tabsByWorktree[worktreeId] ?? []) { - if (tab.agentLaunchPane) { - settleAgentLaunchPaneStart(tab.id, tab.agentLaunchPane.leafId, null) - } - } - for (const tab of s.unifiedTabsByWorktree[worktreeId] ?? []) { - if (tab.contentType === 'agent-session') { - const target = structuredAgentSessionTargetForTab(s, tab) - if (target) { - retireStructuredAgentSessionReadOwner(tab.entityId, target) - } - } - // A retained launch here survived a reload, so its persisted record names its host. - const owner = - tab.contentType === 'agent-session' && !cancelledSessionIds.has(tab.entityId) - ? getStructuredAgentSessionLaunchOwner(tab.entityId) - : undefined - const target = structuredAgentSessionTargetForHost(owner) - if ( - owner && - target && - shouldRetainStructuredAgentSessionLaunchTab(worktreeId, tab.entityId) - ) { - markStructuredAgentSessionLaunchCancelledSilently(worktreeId, tab.entityId, owner) - discardStructuredAgentSessionChatSends(tab.entityId) - clearWebSessionFocusIntentIfMatches( - structuredAgentSessionFocusOwner(target), - worktreeId, - `agent-session:${tab.entityId}` - ) - } - } + retirePurgedTabLaunches( + s, + worktreeId, + s.tabsByWorktree[worktreeId] ?? [], + s.unifiedTabsByWorktree[worktreeId] ?? [], + cancelledSessionIds + ) } pruneHostedReviewLinkMutationGenerations(worktreeIdSet) // Why: ids are repo::path, so a worktree recreated at the same path must not inherit a stale sleep. @@ -85,17 +92,13 @@ export function buildWorktreePurgeState( forgetAmbiguousOwnerWarnings(worktreeIdSet) const doomed = collectWorktreePurgeDoomedIds(s, worktreeIdSet) + const omitters = createWorktreePurgeOmitters(s, worktreeIdSet, doomed) const { omitByWorktree, omitWorkspaceLineageByWorktree, pruneRightSidebarTabByWorktree, - omitByTabId, - omitByPtyId, - omitByPaneKeyTabPrefix, - omitByBrowserWorkspaceId, - omitByPageId, - omitByFileId - } = createWorktreePurgeOmitters(s, worktreeIdSet, doomed) + omitByPaneKeyTabPrefix + } = omitters const removedActive = s.activeWorktreeId != null && worktreeIdSet.has(s.activeWorktreeId) const activeFileCleared = s.activeFileId != null && doomed.removedFileIds.has(s.activeFileId) @@ -117,7 +120,6 @@ export function buildWorktreePurgeState( } return next })() - const nextAgentStatusByPaneKey = omitByPaneKeyTabPrefix(s.agentStatusByPaneKey) const tabPrefixedSleeping = { sleepingAgentSessionsByPaneKey: omitByPaneKeyTabPrefix(s.sleepingAgentSessionsByPaneKey), agentLaunchConfigByPaneKey: omitByPaneKeyTabPrefix(s.agentLaunchConfigByPaneKey) @@ -134,44 +136,10 @@ export function buildWorktreePurgeState( tabIds: doomed.doomedTabIds, activeFileCleared }), + ...buildWorktreePurgeSurfacePatch(s, omitters, doomed.doomedTabIds, nextSleeping), // Worktree-scoped terminal/tab state worktreeLineageById: omitByWorktree(s.worktreeLineageById), workspaceLineageByChildKey: omitWorkspaceLineageByWorktree(s.workspaceLineageByChildKey), - localOnlyScrollbackByTabId: omitByTabId(s.localOnlyScrollbackByTabId), - runtimePaneTitlesByTabId: omitByTabId(s.runtimePaneTitlesByTabId), - automaticAgentResumeClaimsByTabId: omitByTabId(s.automaticAgentResumeClaimsByTabId), - nativeChatLaunchPromptByTabId: omitByTabId(s.nativeChatLaunchPromptByTabId), - nativeChatLaunchDraftByTabId: omitByTabId(s.nativeChatLaunchDraftByTabId), - // Why: bulk/hydration purge runs no terminal teardown, so it must drop the per-tab pane-expand flags itself. - expandedPaneByTabId: omitByTabId(s.expandedPaneByTabId), - canExpandPaneByTabId: omitByTabId(s.canExpandPaneByTabId), - // Why: these per-tab/per-pty terminal+agent maps evict on the single removeWorktree teardown path; the bulk reconcile / remove-project / hydration-stale paths run no teardown, so without these each strands an entry per tab/pane of externally-removed worktrees. - lastKnownRelayPtyIdByTabId: omitByTabId(s.lastKnownRelayPtyIdByTabId), - // Why: liveness-authoritative reconnect maps (orphan sweep reads them); drop purged tabs' entries here too so a re-materialized id can't inherit phantom liveness. - pendingReconnectPtyIdByTabId: omitByTabId(s.pendingReconnectPtyIdByTabId), - unverifiedPtyLossTabIds: omitByTabId(s.unverifiedPtyLossTabIds), - deferredSshSessionIdsByTabId: omitByTabId(s.deferredSshSessionIdsByTabId), - pendingInitialCwdByTabId: omitByTabId(s.pendingInitialCwdByTabId), - pendingIssueCommandSplitByTabId: omitByTabId(s.pendingIssueCommandSplitByTabId), - pendingStartupByTabId: omitByTabId(s.pendingStartupByTabId), - codexRestartNoticeByPtyId: omitByPtyId(s.codexRestartNoticeByPtyId), - migrationUnsupportedByPtyId: omitByPtyId(s.migrationUnsupportedByPtyId), - suppressedPtyExitIds: omitByPtyId(s.suppressedPtyExitIds), - pendingPaneAccountRestarts: omitByPtyId(s.pendingPaneAccountRestarts), - // Why: these agent-status/unread/input maps clear only on the single removeWorktree teardown path; the bulk reconcile / remove-project / hydration-stale paths run no teardown, so without this they orphan an entry per agent pane (plus a phantom unread badge). - // retainedAgentsByPaneKey and runtimeAgentOrchestrationByPaneKey are omitted here — both self-heal (pruneRetainedAgents on worktreesByRepo change; runtime map replaced wholesale each sync). - agentStatusByPaneKey: nextAgentStatusByPaneKey, - ...(nextAgentStatusByPaneKey !== s.agentStatusByPaneKey - ? { agentStatusEpoch: s.agentStatusEpoch + 1 } - : {}), - agentLaunchConfigByPaneKey: nextSleeping.agentLaunchConfigByPaneKey, - acknowledgedAgentsByPaneKey: omitByPaneKeyTabPrefix(s.acknowledgedAgentsByPaneKey), - activityClearedAtByPaneKey: omitByPaneKeyTabPrefix(s.activityClearedAtByPaneKey), - manuallyUnreadTurnsByPaneKey: omitByPaneKeyTabPrefix(s.manuallyUnreadTurnsByPaneKey), - paneForegroundAgentByPaneKey: omitByPaneKeyTabPrefix(s.paneForegroundAgentByPaneKey), - sleepingAgentSessionsByPaneKey: nextSleeping.sleepingAgentSessionsByPaneKey, - ...removeTabsAttention(s, doomed.doomedTabIds), - lastTerminalInputAtByPaneKey: omitByPaneKeyTabPrefix(s.lastTerminalInputAtByPaneKey), // Delete state deleteStateByWorktreeId: removeDeleteStatesForWorktreeIds( s.deleteStateByWorktreeId, @@ -181,21 +149,7 @@ export function buildWorktreePurgeState( remoteBranchConflictByWorktreeId: omitByWorktree(s.remoteBranchConflictByWorktreeId), // File search fileSearchStateByWorktree: omitByWorktree(s.fileSearchStateByWorktree), - // Browser state - browserPagesByWorkspace: omitByBrowserWorkspaceId(s.browserPagesByWorkspace), recentlyClosedBrowserTabsByWorktree: omitByWorktree(s.recentlyClosedBrowserTabsByWorktree), - // Why: keyed by page/workspace id, only cleaned by closeBrowserTab on the single-removal path; the bulk reconcile missed them, orphaning an entry per page of externally-removed worktrees. - browserAnnotationsByPageId: omitByPageId(s.browserAnnotationsByPageId), - browserAnnotationMarkerIdsByPageId: omitByPageId(s.browserAnnotationMarkerIdsByPageId), - remoteBrowserPageHandlesByPageId: omitByPageId(s.remoteBrowserPageHandlesByPageId), - pendingAddressBarFocusByPageId: omitByPageId(s.pendingAddressBarFocusByPageId), - // createBrowserTab writes both the workspace id and the page id into this map. - pendingAddressBarFocusByTabId: omitByPageId( - omitByBrowserWorkspaceId(s.pendingAddressBarFocusByTabId) - ), - recentlyClosedBrowserPagesByWorkspace: omitByBrowserWorkspaceId( - s.recentlyClosedBrowserPagesByWorkspace - ), // Editor state pendingReconnectTabByWorktree: omitByWorktree(s.pendingReconnectTabByWorktree), rightSidebarTabByWorktree: pruneRightSidebarTabByWorktree(), @@ -220,14 +174,6 @@ export function buildWorktreePurgeState( explorerDisplayRootByWorktree: omitByWorktree(s.explorerDisplayRootByWorktree), showDotfilesByWorktree: omitByWorktree(s.showDotfilesByWorktree), expandedDirs: omitByWorktree(s.expandedDirs), - // Per-file editor state for removed files - editorDrafts: omitByFileId(s.editorDrafts), - markdownViewMode: omitByFileId(s.markdownViewMode), - markdownRichModeSizeOverride: omitByFileId(s.markdownRichModeSizeOverride), - markdownFrontmatterVisible: omitByFileId(s.markdownFrontmatterVisible), - // Why: keyed by fileId; the bulk reconcile path previously kept these, leaking a cursor-line / view-mode entry per removed file. - editorCursorLine: omitByFileId(s.editorCursorLine), - editorViewMode: omitByFileId(s.editorViewMode), // Why: keyed by worktreeId; re-keyed on rename but missed by both removal paths (editor-undo / Cmd+Shift+T snapshots). recentlyClosedEditorTabsByWorktree: omitByWorktree(s.recentlyClosedEditorTabsByWorktree), recentlyClosedTerminalTabsByWorktree: omitByWorktree(s.recentlyClosedTerminalTabsByWorktree), @@ -255,34 +201,3 @@ export function buildWorktreePurgeState( })() } } - -/** Cancels provisional structured-chat launches in these worktrees, on the given host only. */ -export function cancelStructuredLaunchesForWorktrees( - worktreeIdSet: ReadonlySet, - ownedByHost?: (worktreeId: string, executionHostId: ExecutionHostId) => boolean -): Set { - const cancelledSessionIds = new Set() - for (const launch of structuredLaunchStates()) { - const worktreeId = launch.intent.worktreeId - if ( - worktreeIdSet.has(worktreeId) && - (!ownedByHost || ownedByHost(worktreeId, launch.intent.executionHostId)) && - shouldRetainStructuredAgentSessionLaunchTab(worktreeId, launch.intent.sessionId) - ) { - markStructuredAgentSessionLaunchCancelledSilently( - worktreeId, - launch.intent.sessionId, - launch.intent.executionHostId - ) - discardStructuredAgentSessionChatSends(launch.intent.sessionId) - retireStructuredAgentSessionReadOwner(launch.intent.sessionId, launch.intent.target) - clearWebSessionFocusIntentIfMatches( - structuredAgentSessionFocusOwner(launch.intent.target), - worktreeId, - `agent-session:${launch.intent.sessionId}` - ) - cancelledSessionIds.add(launch.intent.sessionId) - } - } - return cancelledSessionIds -} diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-surface-patch.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-surface-patch.ts new file mode 100644 index 00000000000..ec718b47f81 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-surface-patch.ts @@ -0,0 +1,88 @@ +import type { AppState } from '../../../types' +import { removeTabsAttention } from '../../workspace-attention/subject-attention-transitions' +import type { createWorktreePurgeOmitters } from './worktree-purge-omitters' + +type WorktreePurgeOmitters = ReturnType + +/** The paneKey-keyed sleeping maps after the caller's own omission policy. */ +export type WorktreePurgeSleepingMaps = Pick< + AppState, + 'sleepingAgentSessionsByPaneKey' | 'agentLaunchConfigByPaneKey' +> + +/** Tab-, PTY-, pane-, page- and file-keyed state of purged surfaces; never a worktree-keyed bucket. */ +export function buildWorktreePurgeSurfacePatch( + s: AppState, + omitters: WorktreePurgeOmitters, + doomedTabIds: ReadonlySet, + sleeping: WorktreePurgeSleepingMaps +): Partial { + const { + omitByTabId, + omitByPtyId, + omitByPaneKeyTabPrefix, + omitByBrowserWorkspaceId, + omitByPageId, + omitByFileId + } = omitters + const nextAgentStatusByPaneKey = omitByPaneKeyTabPrefix(s.agentStatusByPaneKey) + return { + localOnlyScrollbackByTabId: omitByTabId(s.localOnlyScrollbackByTabId), + runtimePaneTitlesByTabId: omitByTabId(s.runtimePaneTitlesByTabId), + automaticAgentResumeClaimsByTabId: omitByTabId(s.automaticAgentResumeClaimsByTabId), + nativeChatLaunchPromptByTabId: omitByTabId(s.nativeChatLaunchPromptByTabId), + nativeChatLaunchDraftByTabId: omitByTabId(s.nativeChatLaunchDraftByTabId), + // Why: bulk/hydration purge runs no terminal teardown, so it must drop the per-tab pane-expand flags itself. + expandedPaneByTabId: omitByTabId(s.expandedPaneByTabId), + canExpandPaneByTabId: omitByTabId(s.canExpandPaneByTabId), + // Why: these per-tab/per-pty terminal+agent maps evict on the single removeWorktree teardown path; the bulk reconcile / remove-project / hydration-stale paths run no teardown, so without these each strands an entry per tab/pane of externally-removed worktrees. + lastKnownRelayPtyIdByTabId: omitByTabId(s.lastKnownRelayPtyIdByTabId), + // Why: liveness-authoritative reconnect maps (orphan sweep reads them); drop purged tabs' entries here too so a re-materialized id can't inherit phantom liveness. + pendingReconnectPtyIdByTabId: omitByTabId(s.pendingReconnectPtyIdByTabId), + unverifiedPtyLossTabIds: omitByTabId(s.unverifiedPtyLossTabIds), + deferredSshSessionIdsByTabId: omitByTabId(s.deferredSshSessionIdsByTabId), + pendingInitialCwdByTabId: omitByTabId(s.pendingInitialCwdByTabId), + pendingIssueCommandSplitByTabId: omitByTabId(s.pendingIssueCommandSplitByTabId), + pendingStartupByTabId: omitByTabId(s.pendingStartupByTabId), + codexRestartNoticeByPtyId: omitByPtyId(s.codexRestartNoticeByPtyId), + migrationUnsupportedByPtyId: omitByPtyId(s.migrationUnsupportedByPtyId), + suppressedPtyExitIds: omitByPtyId(s.suppressedPtyExitIds), + pendingPaneAccountRestarts: omitByPtyId(s.pendingPaneAccountRestarts), + // Why: these agent-status/unread/input maps clear only on the single removeWorktree teardown path; the bulk reconcile / remove-project / hydration-stale paths run no teardown, so without this they orphan an entry per agent pane (plus a phantom unread badge). + // retainedAgentsByPaneKey and runtimeAgentOrchestrationByPaneKey are omitted here — both self-heal (pruneRetainedAgents on worktreesByRepo change; runtime map replaced wholesale each sync). + agentStatusByPaneKey: nextAgentStatusByPaneKey, + ...(nextAgentStatusByPaneKey !== s.agentStatusByPaneKey + ? { agentStatusEpoch: s.agentStatusEpoch + 1 } + : {}), + agentLaunchConfigByPaneKey: sleeping.agentLaunchConfigByPaneKey, + acknowledgedAgentsByPaneKey: omitByPaneKeyTabPrefix(s.acknowledgedAgentsByPaneKey), + activityClearedAtByPaneKey: omitByPaneKeyTabPrefix(s.activityClearedAtByPaneKey), + manuallyUnreadTurnsByPaneKey: omitByPaneKeyTabPrefix(s.manuallyUnreadTurnsByPaneKey), + paneForegroundAgentByPaneKey: omitByPaneKeyTabPrefix(s.paneForegroundAgentByPaneKey), + sleepingAgentSessionsByPaneKey: sleeping.sleepingAgentSessionsByPaneKey, + ...removeTabsAttention(s, doomedTabIds), + lastTerminalInputAtByPaneKey: omitByPaneKeyTabPrefix(s.lastTerminalInputAtByPaneKey), + // Browser state + browserPagesByWorkspace: omitByBrowserWorkspaceId(s.browserPagesByWorkspace), + // Why: keyed by page/workspace id, only cleaned by closeBrowserTab on the single-removal path; the bulk reconcile missed them, orphaning an entry per page of externally-removed worktrees. + browserAnnotationsByPageId: omitByPageId(s.browserAnnotationsByPageId), + browserAnnotationMarkerIdsByPageId: omitByPageId(s.browserAnnotationMarkerIdsByPageId), + remoteBrowserPageHandlesByPageId: omitByPageId(s.remoteBrowserPageHandlesByPageId), + pendingAddressBarFocusByPageId: omitByPageId(s.pendingAddressBarFocusByPageId), + // createBrowserTab writes both the workspace id and the page id into this map. + pendingAddressBarFocusByTabId: omitByPageId( + omitByBrowserWorkspaceId(s.pendingAddressBarFocusByTabId) + ), + recentlyClosedBrowserPagesByWorkspace: omitByBrowserWorkspaceId( + s.recentlyClosedBrowserPagesByWorkspace + ), + // Per-file editor state for removed files + editorDrafts: omitByFileId(s.editorDrafts), + markdownViewMode: omitByFileId(s.markdownViewMode), + markdownRichModeSizeOverride: omitByFileId(s.markdownRichModeSizeOverride), + markdownFrontmatterVisible: omitByFileId(s.markdownFrontmatterVisible), + // Why: keyed by fileId; the bulk reconcile path previously kept these, leaking a cursor-line / view-mode entry per removed file. + editorCursorLine: omitByFileId(s.editorCursorLine), + editorViewMode: omitByFileId(s.editorViewMode) + } +} diff --git a/src/shared/worktree/host-qualified-identity.test.ts b/src/shared/worktree/host-qualified-identity.test.ts index ec61fd8aac5..811f46a8145 100644 --- a/src/shared/worktree/host-qualified-identity.test.ts +++ b/src/shared/worktree/host-qualified-identity.test.ts @@ -5,7 +5,9 @@ import { getExecutionHostIdFromWorktreeHostIdentity, getWorktreeHostIdentity, getWorktreeIdFromHostIdentity, - type HostScopedWorktreeKey + hostWorktreeTarget, + type HostScopedWorktreeKey, + type HostWorktreeTarget } from './host-qualified-identity' const WORKTREE_ID = 'repo-1::/work/orca' @@ -120,3 +122,18 @@ describe('worktree host identity golden bytes', () => { expect([raw, forged]).toEqual([key, WORKTREE_ID]) }) }) + +describe('host worktree target', () => { + it('carries the row id and its concrete host', () => { + const target = hostWorktreeTarget({ id: WORKTREE_ID, hostId: toSshExecutionHostId('box') }) + expect(target).toEqual({ hostId: 'ssh:box', worktreeId: WORKTREE_ID }) + }) + + it('cannot be forged from a plain object or built without a host', () => { + // @ts-expect-error only hostWorktreeTarget mints the brand + const forged: HostWorktreeTarget = { hostId: 'local', worktreeId: WORKTREE_ID } + // @ts-expect-error an unqualified row has no host to scope to + hostWorktreeTarget({ id: WORKTREE_ID, hostId: undefined }) + expect(forged.worktreeId).toBe(WORKTREE_ID) + }) +}) diff --git a/src/shared/worktree/host-qualified-identity.ts b/src/shared/worktree/host-qualified-identity.ts index 20e10a21613..97aca9e4661 100644 --- a/src/shared/worktree/host-qualified-identity.ts +++ b/src/shared/worktree/host-qualified-identity.ts @@ -22,6 +22,23 @@ declare const hostScopedWorktreeKeyBrand: unique symbol // Why: for new code only; existing raw-id stores stay as they are (D7). export type HostScopedWorktreeKey = string & { readonly [hostScopedWorktreeKeyBrand]: true } +declare const hostWorktreeTargetBrand: unique symbol + +/** One host's copy of a workspace, for destructive entry points that must not reach its twin. */ +export type HostWorktreeTarget = { + readonly hostId: ExecutionHostId + readonly worktreeId: string + readonly [hostWorktreeTargetBrand]: true +} + +export function hostWorktreeTarget(row: { + id: string + hostId: ExecutionHostId +}): HostWorktreeTarget { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the only constructor of the brand; `hostId` is a concrete host by the parameter type. + return { hostId: row.hostId, worktreeId: row.id } as HostWorktreeTarget +} + /** * Stable key for one workspace on one host (STA-4343). *