fix(mobile): a re-derived frame changes membership only for the registering surface

The replay re-ran the whole accepted frame, so a surface the host retired
after accept (a phone close whose remote PTY is still exiting, or a closed
chat tab) came back. Every other surface now keeps the host's current
decision; the removal repair is extracted from the terminal retirement
helper so non-terminal tabs are removed the same way.
This commit is contained in:
Brennan Benson
2026-09-27 12:14:42 -07:00
parent b390e48da8
commit f5b99d0003
2 changed files with 78 additions and 48 deletions
@@ -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<string, Set<string>>()
for (const tab of retiredTabs) {
for (const tab of removedTerminals) {
const leafIds = retiredLeafIdsByParent.get(tab.parentTabId) ?? new Set<string>()
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
}
}
@@ -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