diff --git a/src/main/runtime/mobile-session-terminal-retirement.ts b/src/main/runtime/mobile-session-terminal-retirement.ts index 0f027e8c68d..8dc4cf3d722 100644 --- a/src/main/runtime/mobile-session-terminal-retirement.ts +++ b/src/main/runtime/mobile-session-terminal-retirement.ts @@ -216,15 +216,34 @@ export function retireTerminalSurfacesFromSnapshot(args: { if (retiredTabs.length === 0) { return null } + return { + snapshot: removeMobileSessionSnapshotTabs(args.snapshot, retiredTabs, args.retirementProofs), + retired: retiredTabs.map((tab) => ({ + worktreeId: args.snapshot.worktree, + parentTabId: tab.parentTabId, + leafId: tab.leafId, + ptyId: args.ptyId + })) + } +} +/** The snapshot without `removedTabs`, repairing split layouts, tab groups and the active surface. */ +export function removeMobileSessionSnapshotTabs( + snapshot: RuntimeMobileSessionTabsSnapshot, + removedTabs: readonly RuntimeMobileSessionSnapshotTab[], + retirementProofs?: readonly RuntimeMobileSessionRetiredTerminalSurface[] +): RuntimeMobileSessionTabsSnapshot { + const removedTerminals = removedTabs.filter( + (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' + ) const retiredLeafIdsByParent = new Map>() - for (const tab of retiredTabs) { + for (const tab of removedTerminals) { const leafIds = retiredLeafIdsByParent.get(tab.parentTabId) ?? new Set() leafIds.add(tab.leafId) retiredLeafIdsByParent.set(tab.parentTabId, leafIds) } - const retiredIds = new Set(retiredTabs.map((tab) => tab.id)) - let tabs = args.snapshot.tabs.filter((tab) => !retiredIds.has(tab.id)) + const removedIds = new Set(removedTabs.map((tab) => tab.id)) + let tabs = snapshot.tabs.filter((tab) => !removedIds.has(tab.id)) tabs = tabs.map((tab) => { if (tab.type !== 'terminal') { return tab @@ -235,7 +254,7 @@ export function retireTerminalSurfacesFromSnapshot(args: { } const sourceLayout = tab.parentLayout ?? - retiredTabs.find((retired) => retired.parentTabId === tab.parentTabId)?.parentLayout + removedTerminals.find((retired) => retired.parentTabId === tab.parentTabId)?.parentLayout const parentLayout = sourceLayout ? retireLeavesFromTerminalLayout(sourceLayout, retiredLeafIds) : undefined @@ -244,21 +263,18 @@ export function retireTerminalSurfacesFromSnapshot(args: { ...(parentLayout ? { parentLayout } : {}), isActive: tab.isActive || - retiredTabs.some((retired) => retired.parentTabId === tab.parentTabId && retired.isActive) + removedTerminals.some( + (retired) => retired.parentTabId === tab.parentTabId && retired.isActive + ) } }) const validTopLevelIds = new Set(tabs.map(topLevelTabId)) const tabGroups = repairMobileSessionTabGroupsAfterRetirement( - args.snapshot.tabGroups, + snapshot.tabGroups, validTopLevelIds ) - const active = chooseActiveSurface( - tabs, - args.snapshot.activeTabId, - tabGroups, - args.snapshot.activeGroupId - ) + const active = chooseActiveSurface(tabs, snapshot.activeTabId, tabGroups, snapshot.activeGroupId) tabs = tabs.map((tab) => ({ ...tab, isActive: tab.id === active?.id })) const activeTopLevelId = active ? topLevelTabId(active) : null const activeGroupId = @@ -269,38 +285,29 @@ export function retireTerminalSurfacesFromSnapshot(args: { null const retainedGroupIds = new Set(tabGroups?.map((group) => group.id) ?? []) - const retired = retiredTabs.map((tab) => ({ - worktreeId: args.snapshot.worktree, - parentTabId: tab.parentTabId, - leafId: tab.leafId, - ptyId: args.ptyId - })) return { - snapshot: { - ...args.snapshot, - snapshotVersion: args.snapshot.snapshotVersion + 1, - activeGroupId, - activeTabId: active?.id ?? null, - activeTabType: active?.type ?? null, - ...(tabGroups ? { tabGroups } : { tabGroups: undefined }), - ...(args.snapshot.tabGroupLayout - ? { - tabGroupLayout: pruneTabGroupLayoutAfterRetirement( - args.snapshot.tabGroupLayout, - retainedGroupIds - ) - } - : {}), - ...(args.retirementProofs && args.retirementProofs.length > 0 - ? { - retiredTerminalSurfaces: appendRetiredTerminalSurfaceProofs( - args.snapshot.retiredTerminalSurfaces, - args.retirementProofs - ) - } - : {}), - tabs - }, - retired + ...snapshot, + snapshotVersion: snapshot.snapshotVersion + 1, + activeGroupId, + activeTabId: active?.id ?? null, + activeTabType: active?.type ?? null, + ...(tabGroups ? { tabGroups } : { tabGroups: undefined }), + ...(snapshot.tabGroupLayout + ? { + tabGroupLayout: pruneTabGroupLayoutAfterRetirement( + snapshot.tabGroupLayout, + retainedGroupIds + ) + } + : {}), + ...(retirementProofs && retirementProofs.length > 0 + ? { + retiredTerminalSurfaces: appendRetiredTerminalSurfaceProofs( + snapshot.retiredTerminalSurfaces, + retirementProofs + ) + } + : {}), + tabs } } diff --git a/src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts b/src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts index b317d56760f..efe372dc3fc 100644 --- a/src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts +++ b/src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts @@ -4,6 +4,7 @@ import { OrcaRuntimeWithWriteOrchestrationPointerPty } from './orca-runtime-writ import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { getMobileSessionSnapshotTabIdentityKeys } from './mobile-session-tab-merge' +import { removeMobileSessionSnapshotTabs } from './mobile-session-terminal-retirement' export class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOrchestrationPointerPty { // Returns the worktrees whose stored snapshot object changed during this @@ -217,17 +218,39 @@ export class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOr ) { return } - this.mergeRendererMobileSnapshot(accepted.frame) + this.mergeRendererMobileSnapshot(accepted.frame, { parentTabId: tabId, leafId }) this.notifyMobileSessionTabsChanged(worktreeId) } - protected mergeRendererMobileSnapshot(snapshot: RuntimeMobileSessionTabsSnapshot): void { + protected mergeRendererMobileSnapshot( + snapshot: RuntimeMobileSessionTabsSnapshot, + rederivedSurface?: { parentTabId: string; leafId: string } + ): void { const existing = this.mobileSessionTabsByWorktree.get(snapshot.worktree) this.nativeChatDraftResolutions.reconcile(snapshot) const launchDraftFencedSnapshot = this.nativeChatDraftResolutions.applyFence(snapshot) const fencedSnapshot = this.applyMobileSessionRetirementFences(launchDraftFencedSnapshot) - this.releaseRuntimeSessionOwnershipForRendererRetiredTabs(fencedSnapshot, existing) - const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(fencedSnapshot, existing) + // Why: a replay re-derives only the registering surface; every other surface keeps the host's + // current decision, so one the host retired after accept (its PTY may still be exiting) stays out. + const heldKeys = new Set( + existing?.tabs.flatMap((tab) => getMobileSessionSnapshotTabIdentityKeys(tab)) ?? [] + ) + const unheldSurfaces = rederivedSurface + ? fencedSnapshot.tabs.filter( + (tab) => + !( + tab.type === 'terminal' && + tab.parentTabId === rederivedSurface.parentTabId && + tab.leafId === rederivedSurface.leafId + ) && !getMobileSessionSnapshotTabIdentityKeys(tab).some((key) => heldKeys.has(key)) + ) + : [] + const mergedSnapshot = + unheldSurfaces.length > 0 + ? removeMobileSessionSnapshotTabs(fencedSnapshot, unheldSurfaces) + : fencedSnapshot + this.releaseRuntimeSessionOwnershipForRendererRetiredTabs(mergedSnapshot, existing) + const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(mergedSnapshot, existing) // Why: clients drop same-epoch frames whose version isn't strictly newer, // and main-local touches may already have emitted a higher version than // the renderer's counter — keep the stored version strictly monotonic so