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 <noreply@anthropic.com>

* 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 <e2e@test.local>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jinwoo Hong
2026-10-19 21:23:52 -04:00
committed by GitHub
co-authored by Claude E2E Test
parent f1135dd4ca
commit af6fe23623
4 changed files with 101 additions and 11 deletions
@@ -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')
@@ -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'],
@@ -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
)
}
}
@@ -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)