From af6fe236232345bbdd441fa98f693dfaba234512 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:23:52 -0400 Subject: [PATCH] A pane close by its terminal handle closes only that pane, even before the window lists it (dark, layout-owner build only) (#30920) * fix(runtime): main's relisting for a close carries main's tab layout, so a pane close stays a pane close (dark, layout-owner build only) Co-Authored-By: Claude * fix(layout-owner): the relist touches only a row it added, with its own copy of the layout * fix(layout-owner): store the relist's layout only when it changed anything --------- Co-authored-by: E2E Test Co-authored-by: Claude --- ...nal-close-no-widening-layout-owner.test.ts | 6 ++ ...runtime-terminal-close-no-widening.test.ts | 38 +++++++++++++ .../view/layout-owner-close-relisting.ts | 57 ++++++++++++++++--- .../terminal/view/mobile-terminal-surface.ts | 11 +++- 4 files changed, 101 insertions(+), 11 deletions(-) create mode 100644 src/main/runtime/orca-runtime-terminal-close-no-widening-layout-owner.test.ts diff --git a/src/main/runtime/orca-runtime-terminal-close-no-widening-layout-owner.test.ts b/src/main/runtime/orca-runtime-terminal-close-no-widening-layout-owner.test.ts new file mode 100644 index 00000000000..e15ebb33905 --- /dev/null +++ b/src/main/runtime/orca-runtime-terminal-close-no-widening-layout-owner.test.ts @@ -0,0 +1,6 @@ +import { vi } from 'vitest' + +// The same no-widening table, in a layout-owner build: a close must not widen there either. +vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +await import('./orca-runtime-terminal-close-no-widening.test') diff --git a/src/main/runtime/orca-runtime-terminal-close-no-widening.test.ts b/src/main/runtime/orca-runtime-terminal-close-no-widening.test.ts index 1b98c67b8be..b6524d187e0 100644 --- a/src/main/runtime/orca-runtime-terminal-close-no-widening.test.ts +++ b/src/main/runtime/orca-runtime-terminal-close-no-widening.test.ts @@ -30,6 +30,7 @@ type Condition = | 'no-live-pty' | 'kill-fails' | 'nothing-published' + | 'pane-unlisted' /** The desktop renderer's split tab as it publishes it, with the given PTY bindings. */ function syncRendererSplit( @@ -136,6 +137,42 @@ function arrange(entry: Entry, condition: Condition): CloseContinuityHarness { ] }) } + if (condition === 'pane-unlisted') { + // The renderer's list lags main: a sibling row with no layout yet, and none for the closed pane. + harness.runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Fixture shell', + activeLeafId: SIBLING_LEAF_ID, + layout: { type: 'leaf' as const, leafId: SIBLING_LEAF_ID } + } + ], + leaves: [], + mobileSessionTabs: [ + { + worktree: WORKTREE_ID, + publicationEpoch: 'renderer:pane-unlisted', + snapshotVersion: 4, + activeGroupId: null, + activeTabId: `${TAB_ID}::${SIBLING_LEAF_ID}`, + activeTabType: 'terminal' as const, + tabs: [ + { + type: 'terminal' as const, + id: `${TAB_ID}::${SIBLING_LEAF_ID}`, + parentTabId: TAB_ID, + leafId: SIBLING_LEAF_ID, + ptyId: SIBLING_PTY_ID, + title: 'Fixture shell', + isActive: true + } + ] + } + ] + }) + } if (condition === 'kill-fails') { harness.kill.mockReturnValue(false) } @@ -221,6 +258,7 @@ const rows: [Entry, Condition][] = [ ['cli-pty', 'unbound-sibling'], // An owner copy with no panes once read as "one pane", so the close took the whole tab. ['cli-pty', 'nothing-published'], + ['cli-pty', 'pane-unlisted'], ['phone-desktop', 'normal'], ['phone-desktop', 'pinned'], ['phone-desktop', 'unbound-sibling'], diff --git a/src/main/runtime/terminal/view/layout-owner-close-relisting.ts b/src/main/runtime/terminal/view/layout-owner-close-relisting.ts index 7a409d6f5a4..d7f78871f19 100644 --- a/src/main/runtime/terminal/view/layout-owner-close-relisting.ts +++ b/src/main/runtime/terminal/view/layout-owner-close-relisting.ts @@ -6,6 +6,9 @@ import type { WorkspaceSessionState } from '../../../../shared/workspace-session import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' import { terminalLayoutContainsLeaf } from '../../../../shared/workspace-session-pane-ownership' import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import { cloneTerminalLayoutSnapshot } from '../../mobile-session-layout-projection' type RelistedPane = { tabId: string @@ -28,7 +31,11 @@ export function relistPaneMainBinds( host: { hostId: (tabId: string) => ExecutionHostId | undefined session: (hostId: ExecutionHostId) => WorkspaceSessionState | null | undefined - relist: (pty: RuntimePtyWorktreeRecord, pane: RelistedPane) => void + relist: ( + pty: RuntimePtyWorktreeRecord, + pane: RelistedPane, + layout: TerminalLayoutSnapshot + ) => void } ): void { if ( @@ -55,12 +62,44 @@ export function relistPaneMainBinds( ) { return } - host.relist(pty, { - tabId: pane.tabId, - leafId: pane.leafId, - title: null, - activate: false, - selectIfNoActiveTab: false, - notify: false - }) + host.relist( + pty, + { + tabId: pane.tabId, + leafId: pane.leafId, + title: null, + activate: false, + selectIfNoActiveTab: false, + notify: false + }, + layout + ) +} + +/** + * Every row of the relisted pane's tab takes main's layout. A row built for the pane alone holds + * only that pane, and a close reading it would take the pane for the tab's last. Under the switch + * main writes a split before the window does, so its layout is the tab's newest. + */ +export function withMainTabLayout( + snapshot: RuntimeMobileSessionTabsSnapshot, + relisted: { tabId: string; ptyId: string }, + layout: TerminalLayoutSnapshot +): RuntimeMobileSessionTabsSnapshot { + const inTab = (row: RuntimeMobileSessionTabsSnapshot['tabs'][number]): boolean => + row.type === 'terminal' && row.parentTabId === relisted.tabId + // A relist the membership gate refused leaves the window's rows as they were. + if ( + !snapshot.tabs.some( + (row) => inTab(row) && row.type === 'terminal' && row.ptyId === relisted.ptyId + ) + ) { + return snapshot + } + return { + ...snapshot, + tabs: snapshot.tabs.map((row) => + inTab(row) ? { ...row, parentLayout: cloneTerminalLayoutSnapshot(layout) } : row + ) + } } diff --git a/src/main/runtime/terminal/view/mobile-terminal-surface.ts b/src/main/runtime/terminal/view/mobile-terminal-surface.ts index 6338637cb15..6c1748655de 100644 --- a/src/main/runtime/terminal/view/mobile-terminal-surface.ts +++ b/src/main/runtime/terminal/view/mobile-terminal-surface.ts @@ -1,6 +1,6 @@ /* eslint-disable unicorn/no-useless-spread */ import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' -import { relistPaneMainBinds } from './layout-owner-close-relisting' +import { relistPaneMainBinds, withMainTabLayout } from './layout-owner-close-relisting' import { OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal } from '../launch/mobile-terminal-owned-create' import type { OrcaRuntimeWithResolveTerminalSplitSourceAuthority } from '../launch/terminal-split-source-authority' import type { @@ -145,8 +145,15 @@ export abstract class OrcaRuntimeWithWaitForMobileTerminalSurface extends OrcaRu relistPaneMainBinds(authority, live.pty, { hostId: (tabId) => this.getWorkspaceSessionHostIdsForTab(authority.worktreeId, tabId)[0], session: (hostId) => this.store?.getWorkspaceSession?.(hostId), - relist: (pty, pane) => + relist: (pty, pane, layout) => { this.publishPtyBackedMobileSessionTerminal(authority.worktreeId, pty, pane) + const relisted = this.mobileSessionTabsByWorktree.get(authority.worktreeId) + const withLayout = + relisted && withMainTabLayout(relisted, { tabId: pane.tabId, ptyId: pty.ptyId }, layout) + if (withLayout && withLayout !== relisted) { + this.storeMobileSessionSnapshot(authority.worktreeId, withLayout) + } + } }) } const surface = this.findMobileTerminalSurfaceForPty(authority.worktreeId, authority.ptyId)