Close, rename and equalize panes through main (dark, build switch off by default) (#29187)

* feat(layout): add the dark layout.apply channel and route pane splits through main behind ORCA_LAYOUT_OWNER

A build made with ORCA_LAYOUT_OWNER=main registers layout.apply: main applies the
window's layout command to the stored partition through the shared applyLayoutCommand,
minting new ids. A user split then waits for main's answer and mounts the pane id main
minted. Every other build substitutes 'window', so neither branch is compiled in.

* fix(layout): write only the records a layout.apply command touched

A load-then-save rewrite also normalizes records the command never touched. The command's
effect is now the diff between the save before and after it, written onto the stored
partition record by record. A split whose main reply says a paired server holds the tab
splits as today.

* fix(layout): write a load-supplied record whole and check that patched partitions reload to the command's result

* feat(layout): close, rename and equalize panes through main behind ORCA_LAYOUT_OWNER

In a layout-owner build the window's pane close, title change and equalize go through
layout.apply and the window shows main's answer. A window pane close keeps the legacy
close's rules: the tab's last pane is refused, a pane restarted since main read the close is
left alone, every partition holding the tab closes it, and the tab's remote session id follows
the remaining pane. The rules sit behind a command field only layout.apply sets.

* fix(layout): close the tab when a racing pane close leaves the window one pane

* fix(layout): leave an unmounted tab alone when main refuses its last pane

* fix(layout): close a user's pane even if its terminal restarted, as today

Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125

* test(layout): pin the restarted-pane close directly, not through a made-up profile state

Claude-Session: 10569c9a-a0a1-4fc9-8469-0edfb7aed125

* fix(layout): leave a split to today's path only when the workspace's row names a paired server

The split switch point asked which host to route to with a helper that falls back to the focused
server, so a focused server could claim a workspace whose row names no owner. Use the same owner
test the tab mirror uses.

* test(layout): type the split owner mock without a cast

* test(layout): type the layout-owner command mocks with the real signature

* fix(layout): finish the main merge with this branch's versions of the layout-owner files
This commit is contained in:
Jinwoo Hong
2026-10-15 17:37:07 -07:00
committed by GitHub
parent 81374f2099
commit a40ae0ce5a
32 changed files with 1085 additions and 254 deletions
@@ -24,7 +24,6 @@ import type {
} from '../../../shared/terminal-leaf-move'
import {
admitPane,
applyLayout,
moveLeaf,
topologyCommitContext,
withdrawPane,
@@ -36,8 +35,6 @@ import type {
} from '../terminal-topology/terminal-pane-admission'
import type {
CommandOf,
LayoutApplyOutcome,
LayoutCommand,
LayoutRefusalCode
} from '../../../shared/workspace-layout/workspace-layout-command-types'
import { findTerminalBindingConflict } from '../../../shared/workspace-layout/terminal-owner-invariants'
@@ -259,13 +256,6 @@ export class PtyBindingPersistenceOperations {
private topology(): TerminalTopologyCommitContext {
return topologyCommitContext(this[ptyBindingPersistenceOperationsContext])
}
/** A client's layout command (`layout.apply`); the commit module owns the write. */
applyLayoutCommand(command: LayoutCommand, hostId?: string | null): Promise<LayoutApplyOutcome> {
return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation(
applyLayout(resolveHostId(hostId), command, this.topology())
)
}
}
function writePtyBinding(
@@ -19,6 +19,7 @@ import { MobileTabSelectionPersistence } from './mobile-tab-selection-persistenc
import { RemoteLayoutSnapshotPersistence } from '../../remote-layout-snapshot'
import { SparsePresetPersistence } from './sparse-preset-persistence'
import { PtyBindingPersistenceOperations } from './pty-binding-persistence'
import { LayoutCommandPersistence } from '../terminal-topology/layout-command-persistence'
import { SshProfileOperations } from './ssh-profile-operations'
import { RetiredWorktreeNamePersistence } from './retired-worktree-name-persistence'
import { SshLeaseRecoveryOperations } from './ssh-lease-recovery-operations'
@@ -39,6 +40,7 @@ export type StoreDomainOperations = WriteSchedulingOperations &
SessionHostPartitionOperations &
SessionSnapshotOperations &
PtyBindingPersistenceOperations &
LayoutCommandPersistence &
SshProfileOperations &
RetiredWorktreeNamePersistence &
SshLeaseRecoveryOperations &
@@ -67,6 +69,7 @@ export type StoreDomains = {
remoteLayoutSnapshots: RemoteLayoutSnapshotPersistence
sparsePresets: SparsePresetPersistence
ptyBindings: PtyBindingPersistenceOperations
layoutCommands: LayoutCommandPersistence
sshProfiles: SshProfileOperations
retiredWorktreeNames: RetiredWorktreeNamePersistence
sshLeases: SshLeaseRecoveryOperations
@@ -89,6 +92,7 @@ export const STORE_DOMAIN_OPERATION_CLASSES = [
SessionHostPartitionOperations,
SessionSnapshotOperations,
PtyBindingPersistenceOperations,
LayoutCommandPersistence,
SshProfileOperations,
RetiredWorktreeNamePersistence,
SshLeaseRecoveryOperations,
@@ -162,6 +166,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains {
remoteLayoutSnapshots,
sparsePresets,
ptyBindings,
layoutCommands: new LayoutCommandPersistence(runtime, sessions),
sshProfiles,
retiredWorktreeNames,
sshLeases,
@@ -0,0 +1,42 @@
import type {
LayoutApplyOutcome,
LayoutCommand
} from '../../../shared/workspace-layout/workspace-layout-command-types'
import {
resolveHostId,
type SessionHostPartitionOperations
} from '../loading-store/session-host-partitions'
import type { StoreRuntimeState } from '../loading-store/store-runtime-state'
import { applyLayout, topologyCommitContext } from './terminal-topology-commit'
type LayoutCommandRuntime = Pick<
StoreRuntimeState,
'runDurableMutation' | 'state' | 'dirtyProfileStateDomains'
>
const layoutCommandPersistenceContext = Symbol('LayoutCommandPersistence')
type LayoutCommandPersistenceContext = {
runtime: LayoutCommandRuntime
sessions: SessionHostPartitionOperations
}
/** The Store's half of `layout.apply`; the commit module owns the write. */
export class LayoutCommandPersistence {
readonly [layoutCommandPersistenceContext]: LayoutCommandPersistenceContext
constructor(runtime: LayoutCommandRuntime, sessions: SessionHostPartitionOperations) {
this[layoutCommandPersistenceContext] = { runtime, sessions }
}
/** Applies to each partition holding the command's tab, routed partition first (local if none). */
applyLayoutCommand(
command: LayoutCommand,
hostIds: readonly string[] = []
): Promise<LayoutApplyOutcome> {
const context = this[layoutCommandPersistenceContext]
const resolved = hostIds.length > 0 ? hostIds.map(resolveHostId) : [resolveHostId()]
return context.runtime.runDurableMutation(
applyLayout(resolved, command, topologyCommitContext(context))
)
}
}
@@ -0,0 +1,33 @@
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import type { LayoutCommand } from '../../../shared/workspace-layout/workspace-layout-command-types'
/**
* A tab's remote session id is carried data the layout model does not hold, so a pane close moves
* it here: one naming the closed pane's terminal follows the tab's remaining pane, as the legacy
* pane close (`retireTerminalSurfaceFromPersistence`) does.
*/
export function followRemoteSessionAfterPaneClose(
command: LayoutCommand,
stored: WorkspaceSessionState,
written: WorkspaceSessionState
): WorkspaceSessionState {
if (command.type !== 'closePane') {
return written
}
const closedPtyId = stored.terminalLayoutsByTabId[command.tabId]?.ptyIdsByLeafId?.[command.leafId]
const remoteSessionIds = written.remoteSessionIdsByTabId
if (!closedPtyId || remoteSessionIds?.[command.tabId] !== closedPtyId) {
return written
}
const layout = written.terminalLayoutsByTabId[command.tabId]
const next = { ...remoteSessionIds }
const remaining =
layout?.ptyIdsByLeafId?.[layout.activeLeafId ?? ''] ??
Object.values(layout?.ptyIdsByLeafId ?? {})[0]
if (remaining) {
next[command.tabId] = remaining
} else {
delete next[command.tabId]
}
return { ...written, remoteSessionIdsByTabId: next }
}
@@ -126,7 +126,7 @@ describe('Store.applyLayoutCommand (layout.apply)', () => {
const { store } = await storeWithOneTab('ssh:target-1')
const local = store.getWorkspaceSession()
const reply = await store.applyLayoutCommand(split, 'ssh:target-1')
const reply = await store.applyLayoutCommand(split, ['ssh:target-1'])
expect(reply.ok).toBe(true)
const root = store.getWorkspaceSession('ssh:target-1').terminalLayoutsByTabId[TAB]?.root
@@ -0,0 +1,204 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import { diffStoredSession } from '../../../shared/workspace-layout/workspace-layout-load-report'
import { LEGACY_WORKSPACE_LAYOUT_PROFILES } from '../../../shared/workspace-layout/workspace-layout-legacy-profile.test-fixture'
import { WORKSPACE_LAYOUT_PROFILES } from '../../../shared/workspace-layout/workspace-layout-profile.test-fixture'
import {
closeTerminalSurfaceInWorkspaceSession,
collectTerminalLayoutLeafIds
} from '../../runtime/terminal-surface-close'
import { applyCommandToSession } from './terminal-pane-admission'
import { leafIds, WindowSession } from './terminal-topology-window-session-fixture'
import {
emptyTerminalSessionProfile,
FIXTURE_GIT_WORKTREE_ID,
openTopologyStore
} from './terminal-topology-profile-fixture'
vi.mock('../../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../../ssh/ssh-config-parser', () => ({
loadUserSshConfig: () => ({ hosts: [] }),
sshConfigHostsToTargets: () => []
}))
const WORKTREE = FIXTURE_GIT_WORKTREE_ID
const TAB = 'tab-1'
const LEAF_A = '11111111-1111-4111-8111-111111111111'
const LEAF_B = '22222222-2222-4222-8222-222222222222'
const directories: string[] = []
afterEach(() => {
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
/** What only the legacy close writes, which no reader distinguishes from its absence. */
function legacyOnly(change: ReturnType<typeof diffStoredSession>[number]): boolean {
if (change.table !== 'session') {
return false
}
// The legacy close re-derives the active tab's type; layout.apply leaves selection alone.
if (change.field === 'activeTabTypeByWorktree') {
return true
}
// An empty tombstone map where none was stored.
return (
change.field === 'terminalSurfaceTombstonesByPaneKey' &&
change.after === undefined &&
JSON.stringify(change.before) === '{}'
)
}
/**
* Known gap, the same one the record-patch test pins: the load folds this legacy row into the
* tab-bar tab whose terminal it repeats, so the stored row keeps its terminal id after the close.
*/
const FOLDED_LEGACY_ROW_CLOSES = new Map([
[
'tab-legacy',
['row/repo-1::/Users/dev/orca|tab-legacy/ptyId', 'layout/tab-legacy/ptyIdsByLeafId']
]
])
describe('layout.apply closePane against the legacy pane close', () => {
const profiles = [...WORKSPACE_LAYOUT_PROFILES, ...LEGACY_WORKSPACE_LAYOUT_PROFILES]
it.each(profiles.map((profile) => [profile.name, profile] as const))(
'%s: writes what the legacy close writes, and refuses a tab’s last pane',
(_name, profile) => {
const stored = profile.build()
for (const [worktreeId, tabs] of Object.entries(stored.tabsByWorktree)) {
for (const tab of tabs) {
const panes = collectTerminalLayoutLeafIds(stored.terminalLayoutsByTabId[tab.id]?.root)
for (const leafId of panes) {
const target = { kind: 'pane', tabId: tab.id, leafId } as const
const legacy = closeTerminalSurfaceInWorkspaceSession(stored, worktreeId, target, {
reason: 'user'
})
const dark = applyCommandToSession(profile.hostId, stored, {
type: 'closePane',
workspace: worktreeId,
tabId: tab.id,
leafId,
userPaneClose: true
})
const label = `${tab.id}:${leafId}`
if (legacy.resolution !== 'pane') {
// The legacy close changes nothing here; nor may layout.apply (a refusal or no-op).
expect(dark.session, label).toBeNull()
continue
}
expect(dark.value.ok, label).toBe(true)
const differences = diffStoredSession(legacy.session, dark.session ?? stored)
.filter((change) => !legacyOnly(change))
.map(({ table, record, field }) => `${table}/${record}/${field}`)
expect(differences, label).toEqual(FOLDED_LEGACY_ROW_CLOSES.get(tab.id) ?? [])
}
}
}
}
)
// The legacy persisted close refuses a pane restarted after the request, but the window has
// already removed it and saves its layout without it; main closes it outright.
it('closes a pane whose terminal restarted after the window asked', () => {
const window = new WindowSession(getDefaultWorkspaceSession())
window.addTab(WORKTREE, TAB, LEAF_A)
window.setLayout(
TAB,
{
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: LEAF_A },
second: { type: 'leaf', leafId: LEAF_B }
},
{ [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b-restarted' }
)
window.session.terminalPtyIncarnationsByPaneKey = { [`${TAB}:${LEAF_B}`]: 'incarnation-2' }
const closed = applyCommandToSession('local', window.snapshot(), {
type: 'closePane',
workspace: WORKTREE,
tabId: TAB,
leafId: LEAF_B,
userPaneClose: true
})
expect(closed.value.ok).toBe(true)
expect(leafIds(closed.session?.terminalLayoutsByTabId[TAB]?.root)).toEqual([LEAF_A])
expect(closed.session?.terminalPtyIncarnationsByPaneKey?.[`${TAB}:${LEAF_B}`]).toBeUndefined()
})
})
describe('Store.applyLayoutCommand pane operations', () => {
async function storeWithSplitTab(hostIds: string[]) {
const directory = mkdtempSync(join(tmpdir(), 'orca-layout-pane-ops-'))
directories.push(directory)
const store = await openTopologyStore(directory, emptyTerminalSessionProfile())
const window = new WindowSession(getDefaultWorkspaceSession())
window.addTab(WORKTREE, TAB, LEAF_A)
window.setLayout(
TAB,
{
type: 'split',
direction: 'vertical',
ratio: 0.3,
first: { type: 'leaf', leafId: LEAF_A },
second: { type: 'leaf', leafId: LEAF_B }
},
{ [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' }
)
for (const hostId of hostIds) {
store.setWorkspaceSession(window.snapshot(), hostId)
}
return store
}
it('closes the pane in every partition holding the tab', async () => {
const store = await storeWithSplitTab(['local', 'ssh:target-1'])
const reply = await store.applyLayoutCommand(
{ type: 'closePane', workspace: WORKTREE, tabId: TAB, leafId: LEAF_B, userPaneClose: true },
['local', 'ssh:target-1']
)
expect(reply.ok).toBe(true)
for (const hostId of ['local', 'ssh:target-1']) {
expect(leafIds(store.getWorkspaceSession(hostId).terminalLayoutsByTabId[TAB]?.root)).toEqual([
LEAF_A
])
}
})
it('saves a pane title as the window saves it, and clears it on null', async () => {
const store = await storeWithSplitTab(['local'])
const rename = { type: 'renamePane', workspace: WORKTREE, tabId: TAB, leafId: LEAF_B } as const
await store.applyLayoutCommand({ ...rename, title: 'Server' })
expect(store.getWorkspaceSession().terminalLayoutsByTabId[TAB]?.titlesByLeafId).toEqual({
[LEAF_B]: 'Server'
})
await store.applyLayoutCommand({ ...rename, title: null })
expect(store.getWorkspaceSession().terminalLayoutsByTabId[TAB]?.titlesByLeafId ?? {}).toEqual(
{}
)
})
it('equalizes as the window saves an even split: no ratio', async () => {
const store = await storeWithSplitTab(['local'])
await store.applyLayoutCommand({ type: 'equalizePanes', workspace: WORKTREE, tabId: TAB })
expect(store.getWorkspaceSession().terminalLayoutsByTabId[TAB]?.root).toEqual({
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: LEAF_A },
second: { type: 'leaf', leafId: LEAF_B }
})
})
})
@@ -18,6 +18,7 @@ import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace
import { paneKeyOf } from '../../../shared/workspace-layout/workspace-layout-model'
import { saveWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-save'
import { patchStoredSession } from '../../../shared/workspace-layout/workspace-layout-record-patch'
import { followRemoteSessionAfterPaneClose } from './pane-close-remote-session'
/** A new tab, or a split of an existing pane, with the ids its terminal will run under. */
export type TerminalPaneAdmission =
@@ -64,15 +65,16 @@ export function applyCommandToSession(
command: LayoutCommand
): PaneLayoutChange<LayoutApplyOutcome> {
const loaded = loadWorkspaceLayout(hostId, session, nameBasedLoadContext())
return applyToSession(loaded, command, (after) =>
patchStoredSession(session, saveWorkspaceLayout(loaded), after)
)
return applyToSession(loaded, command, (after) => {
const patched = patchStoredSession(session, saveWorkspaceLayout(loaded), after)
return patched === session ? null : followRemoteSessionAfterPaneClose(command, session, patched)
})
}
function applyToSession(
loaded: LoadedWorkspaceLayout,
command: LayoutCommand,
write: (after: WorkspaceSessionState) => WorkspaceSessionState = (after) => after
write: (after: WorkspaceSessionState) => WorkspaceSessionState | null = (after) => after
): PaneLayoutChange<LayoutApplyOutcome> {
const applied = applyLayoutCommand(loaded.layout, command, {
mintId: randomUUID,
@@ -107,20 +107,18 @@ export function withdrawPane(
)
}
/** A client's layout command (`layout.apply`), applied to the stored partition. */
/**
* A client's layout command (`layout.apply`), applied to each partition holding its tab, routed
* partition first: that partition's outcome is the reply, and its refusal writes nothing.
*/
export function applyLayout(
hostId: ExecutionHostId,
hostIds: readonly ExecutionHostId[],
command: LayoutCommand,
context: TerminalTopologyCommitContext
): () => DurableProfileStateMutation<LayoutApplyOutcome> {
return traced(
'apply_layout',
() =>
commitPaneChange(
hostId,
applyCommandToSession(hostId, context.getSession(hostId), command),
context
),
() => commitLayoutCommand(hostIds, command, context),
(outcome) => (outcome.ok ? undefined : outcome.code)
)
}
@@ -260,6 +258,35 @@ function commitLeafMove(
}
}
function commitLayoutCommand(
hostIds: readonly ExecutionHostId[],
command: LayoutCommand,
context: TerminalTopologyCommitContext
): DurableProfileStateMutation<LayoutApplyOutcome> {
const changes = hostIds.map((hostId) => ({
hostId,
change: applyCommandToSession(hostId, context.getSession(hostId), command)
}))
const routed = changes[0]?.change.value
if (!routed) {
return { value: { ok: false, code: 'workspace_not_found' }, persist: false }
}
const writes = routed.ok
? changes.filter(({ change }) => change.value.ok && change.session !== null)
: []
if (writes.length === 0) {
return { value: routed, persist: false }
}
const restores = writes.map(({ hostId, change }) =>
writeRestorable(
() => context.getSession(hostId),
(value) => context.markDirty(assignWorkspaceSessionPartition(context.state, hostId, value)),
keepFrozen(hostId, change.session!, context)
)
)
return { value: routed, rollback: () => restores.forEach((restore) => restore()) }
}
function commitPaneChange<T>(
hostId: ExecutionHostId,
change: PaneLayoutChange<T>,
+10 -17
View File
@@ -26,11 +26,9 @@ import {
import { findWorkspaceReferences } from './runtime-reference-find'
import { createReferenceAgentIndex, referenceConnectionHosts } from './runtime-reference-agents'
import type { RuntimeReferenceFindParams } from '../../shared/runtime-reference-contracts'
import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
import type {
LayoutApplyReply,
LayoutCommand
} from '../../shared/workspace-layout/workspace-layout-command-types'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import type { LayoutApplyReply } from '../../shared/workspace-layout/workspace-layout-command-types'
import { applyWindowLayoutCommand, type WindowLayoutCommand } from './runtime-layout-apply'
import type { TerminalCreateOptions } from './runtime-terminal-contracts'
// oxlint-disable-next-line typescript/no-unsafe-declaration-merging -- SAFETY: The existing Linear installer supplies the canonical prototype surface.
@@ -171,18 +169,13 @@ class OrcaRuntimeService extends OrcaRuntimeWithMigrationCatalog {
return this.workspaceLayoutStream.subscribe(listener)
}
/** A window's layout command (`layout.apply`), applied to the partition that holds the tab. */
async applyLayoutCommand(command: LayoutCommand & { tabId: string }): Promise<LayoutApplyReply> {
const hostId = this.getWorkspaceSessionHostIdForTab(command.workspace, command.tabId)
// Why: a paired server's workspaces keep today's mirrored layout until that server owns it.
if (parseExecutionHostId(hostId)?.kind === 'runtime') {
return { ok: false, code: 'host_not_owned' }
}
const store = this.requireStore()
if (!store.applyLayoutCommand) {
throw new Error('layout_store_unavailable')
}
return store.applyLayoutCommand(command, hostId)
/** A window's layout command (`layout.apply`), applied to the partitions that hold its tab. */
applyLayoutCommand(command: WindowLayoutCommand): Promise<LayoutApplyReply> {
return applyWindowLayoutCommand(
this.requireStore(),
(worktreeId, tabId) => this.getWorkspaceSessionHostIdsForTab(worktreeId, tabId),
command
)
}
/** This desktop's display-only copy of each paired server's layout (`layout.getRemoteSnapshots`). */
@@ -89,6 +89,23 @@ describe('layout.apply', () => {
expect(applyLayoutCommand).toHaveBeenCalledWith(SPLIT)
})
it('takes no close semantics or blank title from the window', async () => {
const apply = (await registeredMethods(true)).find((method) => method.name === 'layout.apply')
const pane = { workspace: WORKTREE, tabId: 'tab-1', leafId: 'leaf-1' }
expect(
apply?.params?.parse({
command: { type: 'closePane', ...pane, userPaneClose: true }
})
).toEqual({ command: { type: 'closePane', ...pane } })
expect(
apply?.params?.safeParse({ command: { type: 'renamePane', ...pane, title: ' ' } }).success
).toBe(false)
expect(
apply?.params?.parse({ command: { type: 'renamePane', ...pane, title: ' Build ' } })
).toEqual({ command: { type: 'renamePane', ...pane, title: 'Build' } })
})
it('applies to the partition that holds the tab, such as an SSH host’s', async () => {
const { runtime, applyLayoutCommand } = runtimeHolding('ssh:target-1')
@@ -96,7 +113,7 @@ describe('layout.apply', () => {
ok: true,
result: { leafId: 'minted' }
})
expect(applyLayoutCommand).toHaveBeenCalledWith(SPLIT, 'ssh:target-1')
expect(applyLayoutCommand).toHaveBeenCalledWith(SPLIT, ['ssh:target-1'])
})
it('refuses a paired server’s workspace, which keeps its mirrored layout', async () => {
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { applyWindowLayoutCommand } from './runtime-layout-apply'
import type { RuntimeStore } from './runtime-store-contract'
const mocks = vi.hoisted(() => ({ markAgentLaunchesClosedByUser: vi.fn() }))
vi.mock('../agent-launch/agent-launch-pane-attachment', () => ({
markAgentLaunchesClosedByUser: mocks.markAgentLaunchesClosedByUser
}))
const CLOSE = { type: 'closePane', workspace: 'wt-1', tabId: 'tab-1', leafId: 'leaf-1' } as const
function storeWith() {
const applyLayoutCommand = vi.fn(async () => ({ ok: true as const, result: {} }))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the apply path reads only applyLayoutCommand.
const store = { applyLayoutCommand } as unknown as RuntimeStore
return { store, applyLayoutCommand }
}
describe('applyWindowLayoutCommand', () => {
beforeEach(() => mocks.markAgentLaunchesClosedByUser.mockReset())
it('closes a pane as the user’s close, in every local or SSH holder', async () => {
const { store, applyLayoutCommand } = storeWith()
await applyWindowLayoutCommand(store, () => ['local', 'runtime:env-1', 'ssh:target-1'], CLOSE)
expect(applyLayoutCommand).toHaveBeenCalledWith({ ...CLOSE, userPaneClose: true }, [
'local',
'ssh:target-1'
])
expect(mocks.markAgentLaunchesClosedByUser).toHaveBeenCalledWith('wt-1', {
kind: 'pane',
tabId: 'tab-1',
leafId: 'leaf-1'
})
})
it('applies other commands to the routed partition only', async () => {
const { store, applyLayoutCommand } = storeWith()
const rename = { ...CLOSE, type: 'renamePane', title: 'Pane' } as const
await applyWindowLayoutCommand(store, () => ['local', 'ssh:target-1'], rename)
expect(applyLayoutCommand).toHaveBeenCalledWith(rename, ['local'])
expect(mocks.markAgentLaunchesClosedByUser).not.toHaveBeenCalled()
})
it('refuses a tab a paired server routes, touching nothing', async () => {
const { store, applyLayoutCommand } = storeWith()
await expect(
applyWindowLayoutCommand(store, () => ['runtime:env-1', 'local'], CLOSE)
).resolves.toEqual({ ok: false, code: 'host_not_owned' })
expect(applyLayoutCommand).not.toHaveBeenCalled()
expect(mocks.markAgentLaunchesClosedByUser).not.toHaveBeenCalled()
})
})
+40
View File
@@ -0,0 +1,40 @@
import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import type {
LayoutApplyReply,
LayoutCommand
} from '../../shared/workspace-layout/workspace-layout-command-types'
import { markAgentLaunchesClosedByUser } from '../agent-launch/agent-launch-pane-attachment'
import type { RuntimeStore } from './runtime-store-contract'
/** A window's layout command: one on a tab, which names the partitions it applies to. */
export type WindowLayoutCommand = LayoutCommand & { tabId: string }
/**
* `layout.apply`: the window's command on the partitions holding its tab, routed partition first.
* A pane close is the user's: main marks it so, and the window cannot.
*/
export async function applyWindowLayoutCommand(
store: RuntimeStore,
hostIdsForTab: (worktreeId: string, tabId: string) => ExecutionHostId[],
command: WindowLayoutCommand
): Promise<LayoutApplyReply> {
const hostIds = hostIdsForTab(command.workspace, command.tabId)
// Why: a paired server's workspaces keep today's mirrored layout until that server owns it.
if (!hostIds[0] || parseExecutionHostId(hostIds[0])?.kind === 'runtime') {
return { ok: false, code: 'host_not_owned' }
}
if (!store.applyLayoutCommand) {
throw new Error('layout_store_unavailable')
}
if (command.type !== 'closePane') {
return store.applyLayoutCommand(command, [hostIds[0]])
}
const { workspace, tabId, leafId } = command
// A launch still starting in the pane the user closed stops, and says so (as the legacy close).
markAgentLaunchesClosedByUser(workspace, { kind: 'pane', tabId, leafId })
// Why every holder: a legacy relay reattach can leave one tab in two partitions.
return store.applyLayoutCommand(
{ ...command, userPaneClose: true },
hostIds.filter((hostId) => parseExecutionHostId(hostId)?.kind !== 'runtime')
)
}
@@ -0,0 +1,112 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { preparePendingSplitClose } from './pending-split-close-test-fixture'
import type { sendPaneCommandToLayoutOwner } from './terminal-pane-layout-owner-commands'
const mocks = vi.hoisted(() => ({
layoutOwnerIsMain: false,
commitTerminalSurfaceClose: vi.fn(),
sendPaneCommandToLayoutOwner: vi.fn<typeof sendPaneCommandToLayoutOwner>()
}))
vi.mock('../../../../shared/layout-owner-switch', () => ({
get ORCA_LAYOUT_OWNER_IS_MAIN() {
return mocks.layoutOwnerIsMain
}
}))
vi.mock('./terminal-pane-layout-owner-commands', () => ({
sendPaneCommandToLayoutOwner: mocks.sendPaneCommandToLayoutOwner
}))
vi.mock('@/store/terminals/terminal-surface-close-intent', () => ({
commitTerminalSurfaceClose: mocks.commitTerminalSurfaceClose
}))
const LEAF = '11111111-1111-4111-8111-111111111111'
beforeEach(() => {
mocks.layoutOwnerIsMain = false
mocks.commitTerminalSurfaceClose.mockReset()
mocks.sendPaneCommandToLayoutOwner.mockReset()
mocks.sendPaneCommandToLayoutOwner.mockReturnValue(true)
})
it('a window-owned build closes the pane at once and reports it to main', async () => {
const p = await preparePendingSplitClose()
p.actions.executeClosePane(1)
expect(mocks.sendPaneCommandToLayoutOwner).not.toHaveBeenCalled()
expect(mocks.commitTerminalSurfaceClose).toHaveBeenCalledWith('workspace', {
kind: 'pane',
tabId: 'tab-parent',
leafId: LEAF
})
expect(p.transports.has(1)).toBe(false)
})
it('a layout-owner build removes the pane only once main has closed it', async () => {
mocks.layoutOwnerIsMain = true
const p = await preparePendingSplitClose()
p.actions.executeClosePane(1)
expect(mocks.sendPaneCommandToLayoutOwner).toHaveBeenCalledWith(
'workspace',
{ type: 'closePane', tabId: 'tab-parent', leafId: LEAF },
{ apply: expect.any(Function), asToday: expect.any(Function), refused: expect.any(Function) }
)
expect(p.transports.has(1)).toBe(true)
mocks.sendPaneCommandToLayoutOwner.mock.calls[0]?.[2].apply({})
expect(p.transports.has(1)).toBe(false)
expect(mocks.commitTerminalSurfaceClose).not.toHaveBeenCalled()
})
it('a layout-owner build closes as today when a paired server holds the tab', async () => {
mocks.layoutOwnerIsMain = true
const p = await preparePendingSplitClose()
p.actions.executeClosePane(1)
mocks.sendPaneCommandToLayoutOwner.mock.calls[0]?.[2].asToday()
expect(mocks.commitTerminalSurfaceClose).toHaveBeenCalledOnce()
expect(p.transports.has(1)).toBe(false)
})
it('a layout-owner build closes the tab when main refuses a second close as the last pane', async () => {
mocks.layoutOwnerIsMain = true
const p = await preparePendingSplitClose()
p.actions.executeClosePane(1)
p.actions.executeClosePane(2)
const [first, second] = mocks.sendPaneCommandToLayoutOwner.mock.calls.map(([, , on]) => on)
first?.apply({})
second?.refused?.('last_pane')
expect(p.controller.onCloseTab).toHaveBeenCalledOnce()
})
it('a layout-owner build closes the tab when main’s answer finds the pane alone', async () => {
mocks.layoutOwnerIsMain = true
const p = await preparePendingSplitClose()
p.actions.executeClosePane(1)
p.actions.executeClosePane(2)
const [first, second] = mocks.sendPaneCommandToLayoutOwner.mock.calls.map(([, , on]) => on)
first?.apply({})
second?.apply({})
expect(p.controller.onCloseTab).toHaveBeenCalledOnce()
expect(p.controller.managerRef.current?.getPanes()).toHaveLength(1)
})
it('a layout-owner build leaves an unmounted tab alone when main refuses its last pane', async () => {
mocks.layoutOwnerIsMain = true
const p = await preparePendingSplitClose()
p.actions.executeClosePane(1)
p.controller.managerRef.current = null
mocks.sendPaneCommandToLayoutOwner.mock.calls[0]?.[2].refused?.('last_pane')
expect(p.controller.onCloseTab).not.toHaveBeenCalled()
})
@@ -9,6 +9,7 @@ import {
syncTerminalScrollIntentFromViewport
} from '@/lib/pane-manager/terminal-scroll-intent'
import type { resolveTerminalKeyboardShortcutAction } from './terminal-keyboard-shortcut-matching'
import { equalizeTerminalPanes } from './terminal-pane-layout-owner-commands'
type TerminalShortcutAction = NonNullable<ReturnType<typeof resolveTerminalKeyboardShortcutAction>>
@@ -164,8 +165,9 @@ export function dispatchTerminalShortcutAction(
if (expandedPaneIdRef.current !== null) {
return
}
manager.equalizePaneSizes()
;(manager.getActivePane() ?? manager.getPanes()[0])?.terminal.focus()
equalizeTerminalPanes({ worktreeId, tabId, getManager: () => managerRef.current }, (current) =>
(current.getActivePane() ?? current.getPanes()[0])?.terminal.focus()
)
return
}
if (action.type === 'toggleExpandActivePane') {
@@ -2,10 +2,24 @@
import { cleanup, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useTerminalPaneLayoutPersistence } from './use-terminal-pane-layout-persistence'
import type { sendPaneCommandToLayoutOwner } from './terminal-pane-layout-owner-commands'
const LEFT = '11111111-1111-4111-8111-111111111111'
const RIGHT = '22222222-2222-4222-8222-222222222222'
const mocks = vi.hoisted(() => ({
layoutOwnerIsMain: false,
sendPaneCommandToLayoutOwner: vi.fn<typeof sendPaneCommandToLayoutOwner>()
}))
vi.mock('../../../../shared/layout-owner-switch', () => ({
get ORCA_LAYOUT_OWNER_IS_MAIN() {
return mocks.layoutOwnerIsMain
}
}))
vi.mock('./terminal-pane-layout-owner-commands', () => ({
sendPaneCommandToLayoutOwner: mocks.sendPaneCommandToLayoutOwner
}))
vi.mock('../../store', () => ({
useAppStore: { getState: () => ({ terminalLayoutsByTabId: {} }) }
}))
@@ -69,7 +83,11 @@ function renderPersistence() {
return { hook, push, setTabLayout, pushedIntents, panes }
}
afterEach(cleanup)
afterEach(() => {
cleanup()
mocks.layoutOwnerIsMain = false
mocks.sendPaneCommandToLayoutOwner.mockReset()
})
describe('terminal layout gesture persist', () => {
it('saves the same layout for a gesture and an automatic persist and marks only the push', () => {
@@ -109,4 +127,35 @@ describe('terminal layout gesture persist', () => {
expect(pushedIntents()).toEqual(['gesture', 'gesture'])
})
it('a window-owned build saves a renamed pane at once, without asking main', () => {
const { hook, pushedIntents, push } = renderPersistence()
push.mockClear()
hook.result.current.changePaneTitle(2, 'server')
expect(mocks.sendPaneCommandToLayoutOwner).not.toHaveBeenCalled()
expect(pushedIntents()).toEqual(['gesture'])
})
it('a layout-owner build shows a title change only once main has saved it', () => {
mocks.layoutOwnerIsMain = true
mocks.sendPaneCommandToLayoutOwner.mockReturnValue(true)
const { hook, pushedIntents, push } = renderPersistence()
push.mockClear()
hook.result.current.changePaneTitle(2, 'server')
hook.result.current.removePaneTitle(1)
expect(mocks.sendPaneCommandToLayoutOwner.mock.calls.map(([, command]) => command)).toEqual([
{ type: 'renamePane', tabId: 'tab', leafId: RIGHT, title: 'server' },
{ type: 'renamePane', tabId: 'tab', leafId: LEFT, title: null }
])
expect(pushedIntents()).toEqual([])
for (const [, , handlers] of mocks.sendPaneCommandToLayoutOwner.mock.calls) {
handlers.apply({})
}
expect(pushedIntents()).toEqual(['gesture', 'gesture'])
})
})
@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import {
equalizeTerminalPanes,
sendPaneCommandToLayoutOwner
} from './terminal-pane-layout-owner-commands'
const mocks = vi.hoisted(
(): {
callRuntimeRpc: ReturnType<typeof vi.fn>
ownerEnvironmentId: string | null
layoutOwnerIsMain: boolean
} => ({
callRuntimeRpc: vi.fn(),
ownerEnvironmentId: null,
layoutOwnerIsMain: false
})
)
vi.mock('../../../../shared/layout-owner-switch', () => ({
get ORCA_LAYOUT_OWNER_IS_MAIN() {
return mocks.layoutOwnerIsMain
}
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc }))
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getExplicitRuntimeEnvironmentIdForWorktree: () => mocks.ownerEnvironmentId
}))
const SPLIT = {
type: 'splitPane',
tabId: 'tab-1',
leafId: 'leaf-1',
direction: 'vertical'
} as const
function handlers() {
return { apply: vi.fn(), asToday: vi.fn(), refused: vi.fn() }
}
describe('sendPaneCommandToLayoutOwner', () => {
beforeEach(() => {
mocks.callRuntimeRpc.mockReset()
mocks.ownerEnvironmentId = null
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
it('asks local main first, then applies main’s result', async () => {
mocks.callRuntimeRpc.mockResolvedValue({ ok: true, result: { leafId: 'leaf-minted' } })
const on = handlers()
expect(sendPaneCommandToLayoutOwner('worktree-1', SPLIT, on)).toBe(true)
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith({ kind: 'local' }, 'layout.apply', {
command: { ...SPLIT, workspace: 'worktree-1' }
})
expect(on.apply).not.toHaveBeenCalled()
await vi.waitFor(() => expect(on.apply).toHaveBeenCalledWith({ leafId: 'leaf-minted' }))
expect(on.asToday).not.toHaveBeenCalled()
})
it.each([
['a refusal', () => mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'last_pane' })],
['a failed call', () => mocks.callRuntimeRpc.mockRejectedValue(new Error('rpc_failed'))]
])('changes nothing after %s', async (_case, arrange) => {
arrange()
const on = handlers()
expect(sendPaneCommandToLayoutOwner('worktree-1', SPLIT, on)).toBe(true)
await vi.waitFor(() => expect(console.warn).toHaveBeenCalled())
expect(on.apply).not.toHaveBeenCalled()
expect(on.asToday).not.toHaveBeenCalled()
})
it('hands any other refusal to the caller', async () => {
mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'last_pane' })
const on = handlers()
sendPaneCommandToLayoutOwner('worktree-1', SPLIT, on)
await vi.waitFor(() => expect(on.refused).toHaveBeenCalledWith('last_pane'))
})
it('acts as today when main says a paired server holds the tab', async () => {
mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'host_not_owned' })
const on = handlers()
expect(sendPaneCommandToLayoutOwner('worktree-1', SPLIT, on)).toBe(true)
await vi.waitFor(() => expect(on.asToday).toHaveBeenCalled())
expect(on.apply).not.toHaveBeenCalled()
})
it('leaves a paired server’s workspace to today’s path', () => {
mocks.ownerEnvironmentId = 'env-1'
expect(sendPaneCommandToLayoutOwner('worktree-1', SPLIT, handlers())).toBe(false)
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
})
})
describe('equalizeTerminalPanes', () => {
beforeEach(() => {
mocks.callRuntimeRpc.mockReset()
mocks.ownerEnvironmentId = null
mocks.layoutOwnerIsMain = false
})
function manager() {
const equalizePaneSizes = vi.fn()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: equalize calls only equalizePaneSizes.
return { equalizePaneSizes } as unknown as PaneManager & {
equalizePaneSizes: typeof equalizePaneSizes
}
}
it('a window-owned build equalizes at once, without asking main', () => {
const current = manager()
const then = vi.fn()
equalizeTerminalPanes(
{ worktreeId: 'worktree-1', tabId: 'tab-1', getManager: () => current },
then
)
expect(current.equalizePaneSizes).toHaveBeenCalledOnce()
expect(then).toHaveBeenCalledWith(current)
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
})
it('a layout-owner build equalizes once main has', async () => {
mocks.layoutOwnerIsMain = true
mocks.callRuntimeRpc.mockResolvedValue({ ok: true, result: {} })
const current = manager()
equalizeTerminalPanes(
{ worktreeId: 'worktree-1', tabId: 'tab-1', getManager: () => current },
vi.fn()
)
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith({ kind: 'local' }, 'layout.apply', {
command: { type: 'equalizePanes', tabId: 'tab-1', workspace: 'worktree-1' }
})
expect(current.equalizePaneSizes).not.toHaveBeenCalled()
await vi.waitFor(() => expect(current.equalizePaneSizes).toHaveBeenCalledOnce())
})
})
@@ -0,0 +1,82 @@
import { useAppStore } from '@/store'
import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import type {
LayoutApplyReply,
LayoutCommandResult,
LayoutRefusalCode
} from '../../../../shared/workspace-layout/workspace-layout-command-types'
/** The pane commands a window sends `layout.apply`; main mints any new id. */
export type PaneLayoutCommand =
| { type: 'splitPane'; tabId: string; leafId: string; direction: 'vertical' | 'horizontal' }
| { type: 'closePane'; tabId: string; leafId: string }
| { type: 'renamePane'; tabId: string; leafId: string; title: string | null }
| { type: 'equalizePanes'; tabId: string }
/**
* A pane command in a layout-owner build: main applies it first, then `apply` shows main's result.
* `asToday` runs instead when main says a paired server holds the tab (#18202); `refused` on any
* other refusal. False when a paired server owns the workspace's layout; the caller acts as today.
*/
export function sendPaneCommandToLayoutOwner(
worktreeId: string,
command: PaneLayoutCommand,
handlers: {
apply: (result: LayoutCommandResult) => void
asToday: () => void
refused?: (code: LayoutRefusalCode) => void
}
): boolean {
// Why: the same owner test that mirrors a workspace's tabs from a paired server; a focused
// server must not claim a workspace whose row names no owner.
if (getExplicitRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId)) {
return false
}
void callRuntimeRpc<LayoutApplyReply>({ kind: 'local' }, 'layout.apply', {
command: { ...command, workspace: worktreeId }
})
.then((reply) => {
if (reply.ok) {
handlers.apply(reply.result)
} else if (reply.code === 'host_not_owned') {
handlers.asToday()
} else {
console.warn(`[layout-owner] ${command.type} refused:`, reply.code)
handlers.refused?.(reply.code)
}
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : error
console.warn(`[layout-owner] ${command.type} failed:`, message)
})
return true
}
/** Equalizes a tab's panes, then `then` runs (focus); in a layout-owner build main equalizes first. */
export function equalizeTerminalPanes(
pane: { worktreeId: string; tabId: string; getManager: () => PaneManager | null },
then: (manager: PaneManager) => void
): void {
const equalize = (): void => {
const manager = pane.getManager()
if (manager) {
manager.equalizePaneSizes()
then(manager)
}
}
// Switch point: main equalizes the tab first; the window then redraws it.
if (
ORCA_LAYOUT_OWNER_IS_MAIN &&
sendPaneCommandToLayoutOwner(
pane.worktreeId,
{ type: 'equalizePanes', tabId: pane.tabId },
{ apply: equalize, asToday: equalize }
)
) {
return
}
equalize()
}
@@ -1,80 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { splitThroughLayoutOwner } from './terminal-pane-split-through-layout-owner'
const mocks = vi.hoisted(
(): { callRuntimeRpc: ReturnType<typeof vi.fn>; ownerEnvironmentId: string | null } => ({
callRuntimeRpc: vi.fn(),
ownerEnvironmentId: null
})
)
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc }))
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getExplicitRuntimeEnvironmentIdForWorktree: () => mocks.ownerEnvironmentId
}))
const SPLIT = {
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
direction: 'vertical'
} as const
describe('splitThroughLayoutOwner', () => {
beforeEach(() => {
mocks.callRuntimeRpc.mockReset()
mocks.ownerEnvironmentId = null
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
it('asks local main for the split and mounts the pane id it minted', async () => {
mocks.callRuntimeRpc.mockResolvedValue({ ok: true, result: { leafId: 'leaf-minted' } })
const mount = vi.fn()
expect(splitThroughLayoutOwner(SPLIT, mount)).toBe(true)
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith({ kind: 'local' }, 'layout.apply', {
command: {
type: 'splitPane',
workspace: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
direction: 'vertical'
}
})
await vi.waitFor(() => expect(mount).toHaveBeenCalledWith('leaf-minted'))
})
it.each([
[
'a refusal',
() => mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'pane_not_found' })
],
['a failed call', () => mocks.callRuntimeRpc.mockRejectedValue(new Error('rpc_failed'))]
])('mounts nothing after %s', async (_case, arrange) => {
arrange()
const mount = vi.fn()
expect(splitThroughLayoutOwner(SPLIT, mount)).toBe(true)
await vi.waitFor(() => expect(console.warn).toHaveBeenCalled())
expect(mount).not.toHaveBeenCalled()
})
it('splits as today when main says a paired server holds the tab', async () => {
mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'host_not_owned' })
const mount = vi.fn()
expect(splitThroughLayoutOwner(SPLIT, mount)).toBe(true)
await vi.waitFor(() => expect(mount).toHaveBeenCalledWith(undefined))
})
it('leaves a paired server’s workspace to today’s split', () => {
mocks.ownerEnvironmentId = 'env-1'
expect(splitThroughLayoutOwner(SPLIT, vi.fn())).toBe(false)
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
})
})
@@ -1,43 +0,0 @@
import { useAppStore } from '@/store'
import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import type { LayoutApplyReply } from '../../../../shared/workspace-layout/workspace-layout-command-types'
/**
* A split in a layout-owner build: main writes it and mints the new pane's id, then `mount` shows
* that pane (no id: split as today). False when a paired server owns the workspace's layout; the
* caller splits as today.
*/
export function splitThroughLayoutOwner(
split: {
worktreeId: string
tabId: string
leafId: string
direction: 'vertical' | 'horizontal'
},
mount: (leafId: string | undefined) => void
): boolean {
// Why: the same owner test that mirrors a workspace's tabs from a paired server; a focused
// server must not claim a workspace whose row names no owner.
if (getExplicitRuntimeEnvironmentIdForWorktree(useAppStore.getState(), split.worktreeId)) {
return false
}
const { worktreeId: workspace, ...pane } = split
void callRuntimeRpc<LayoutApplyReply>({ kind: 'local' }, 'layout.apply', {
command: { type: 'splitPane', workspace, ...pane }
})
.then((reply) => {
if (reply.ok && reply.result.leafId) {
mount(reply.result.leafId)
} else if (!reply.ok && reply.code === 'host_not_owned') {
// A tab a paired server holds inside a local workspace (#18202): split it as today.
mount(undefined)
} else {
console.warn('[layout-owner] split refused:', reply.ok ? 'no_leaf' : reply.code)
}
})
.catch((error: unknown) => {
console.warn('[layout-owner] split failed:', error instanceof Error ? error.message : error)
})
return true
}
@@ -9,8 +9,14 @@ const mocks = vi.hoisted(() => ({
layoutOwnerIsMain: false,
recordCreatedTerminalPaneSplit: vi.fn(),
resolveSplitCwd: vi.fn(),
splitThroughLayoutOwner:
vi.fn<(split: unknown, mount: (leafId: string | undefined) => void) => boolean>(),
sendPaneCommandToLayoutOwner:
vi.fn<
(
worktreeId: string,
command: unknown,
handlers: { apply: (result: { leafId?: string }) => void; asToday: () => void }
) => boolean
>(),
splitWebRuntimeTerminal: vi.fn()
}))
@@ -20,8 +26,8 @@ vi.mock('../../../../shared/layout-owner-switch', () => ({
}
}))
vi.mock('./terminal-pane-split-through-layout-owner', () => ({
splitThroughLayoutOwner: mocks.splitThroughLayoutOwner
vi.mock('./terminal-pane-layout-owner-commands', () => ({
sendPaneCommandToLayoutOwner: mocks.sendPaneCommandToLayoutOwner
}))
vi.mock('@/runtime/web-runtime-terminal-actions', () => ({
@@ -43,7 +49,7 @@ function makeManager(splitPane: ReturnType<typeof vi.fn>): PaneManager {
describe('splitTerminalPaneWithInheritedCwd', () => {
beforeEach(() => {
mocks.layoutOwnerIsMain = false
mocks.splitThroughLayoutOwner.mockReset()
mocks.sendPaneCommandToLayoutOwner.mockReset()
mocks.recordCreatedTerminalPaneSplit.mockReset()
mocks.resolveSplitCwd.mockReset()
mocks.splitWebRuntimeTerminal.mockReset()
@@ -240,13 +246,13 @@ describe('splitTerminalPaneWithInheritedCwd', () => {
source: 'keyboard'
})
expect(mocks.splitThroughLayoutOwner).not.toHaveBeenCalled()
expect(mocks.sendPaneCommandToLayoutOwner).not.toHaveBeenCalled()
expect(splitPane).toHaveBeenCalledWith(1, 'vertical', { cwd: '/cached' })
})
it('in a layout-owner build, mounts the pane main minted only once main answers', () => {
mocks.layoutOwnerIsMain = true
mocks.splitThroughLayoutOwner.mockReturnValue(true)
mocks.sendPaneCommandToLayoutOwner.mockReturnValue(true)
const createdPane = { id: 2 }
const splitPane = vi.fn(() => createdPane)
@@ -262,13 +268,14 @@ describe('splitTerminalPaneWithInheritedCwd', () => {
source: 'keyboard'
})
expect(mocks.splitThroughLayoutOwner).toHaveBeenCalledWith(
{ worktreeId: 'worktree-1', tabId: 'tab-1', leafId: 'leaf-1', direction: 'vertical' },
expect.any(Function)
expect(mocks.sendPaneCommandToLayoutOwner).toHaveBeenCalledWith(
'worktree-1',
{ type: 'splitPane', tabId: 'tab-1', leafId: 'leaf-1', direction: 'vertical' },
{ apply: expect.any(Function), asToday: expect.any(Function) }
)
expect(splitPane).not.toHaveBeenCalled()
mocks.splitThroughLayoutOwner.mock.calls[0]?.[1]('leaf-minted')
mocks.sendPaneCommandToLayoutOwner.mock.calls[0]?.[2].apply({ leafId: 'leaf-minted' })
expect(splitPane).toHaveBeenCalledWith(1, 'vertical', {
leafId: 'leaf-minted',
@@ -282,7 +289,7 @@ describe('splitTerminalPaneWithInheritedCwd', () => {
it('in a layout-owner build, splits as today where a paired server owns the layout', () => {
mocks.layoutOwnerIsMain = true
mocks.splitThroughLayoutOwner.mockReturnValue(false)
mocks.sendPaneCommandToLayoutOwner.mockReturnValue(false)
const splitPane = vi.fn(() => ({ id: 2 }))
splitTerminalPaneWithInheritedCwd({
@@ -5,7 +5,7 @@ import type { PtyTransport } from './pty-transport-types'
import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd'
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { splitThroughLayoutOwner } from './terminal-pane-split-through-layout-owner'
import { sendPaneCommandToLayoutOwner } from './terminal-pane-layout-owner-commands'
type SplitArgs = {
worktreeId: string
@@ -34,14 +34,13 @@ export function splitTerminalPaneWithInheritedCwd(args: SplitArgs): void {
// Switch point: main writes the split and mints the new pane's id; the window then mounts it.
if (
ORCA_LAYOUT_OWNER_IS_MAIN &&
splitThroughLayoutOwner(
sendPaneCommandToLayoutOwner(
args.worktreeId,
{ type: 'splitPane', tabId: args.tabId, leafId: args.pane.leafId, direction: args.direction },
{
worktreeId: args.worktreeId,
tabId: args.tabId,
leafId: args.pane.leafId,
direction: args.direction
},
(leafId) => splitLocally(args, ptyId, leafId)
apply: ({ leafId }) => leafId && splitLocally(args, ptyId, leafId),
asToday: () => splitLocally(args, ptyId)
}
)
) {
return
@@ -19,6 +19,8 @@ import { retireUnboundIpcTerminalPane } from './retire-unbound-ipc-terminal-pane
import { capturePendingTerminalPaneClose } from './terminal-pane-close-admission'
import { commitTerminalSurfaceClose } from '@/store/terminals/terminal-surface-close-intent'
import { noteAgentLaunchPaneClosedByUser } from '@/lib/agent-launch-pane-closes'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { sendPaneCommandToLayoutOwner } from './terminal-pane-layout-owner-commands'
export function useTerminalPaneCloseActions(controller: TerminalPaneBindingController) {
const confirmedCloseRef = useRef<(() => void) | null>(null)
@@ -38,53 +40,58 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr
updateSettings,
worktreeId
} = controller
const executeClosePane = useCallback(
(paneId: number) => {
// The window's half of a split pane's close: its transport, status and pane go.
const closePaneLocally = useCallback(
(paneId: number, committedLeafId?: string) => {
const manager = managerRef.current
if (!manager) {
// Why the pane check: main's answer can land after the pane already went.
if (!manager || (committedLeafId && manager.getLeafId(paneId) !== committedLeafId)) {
return
}
if (manager.getPanes().length <= 1) {
// A sibling went while main answered: this pane is now the tab's last, so the tab closes.
if (committedLeafId && manager.getPanes().length <= 1) {
onCloseTab()
} else {
const ptyId = paneTransportsRef.current.get(paneId)?.getPtyId() ?? null
closeWebRuntimeTerminal(ptyId)
clearSessionRestoredBannerForPane(paneId)
const leafId = manager.getLeafId(paneId)
if (leafId) {
const launchPane = useAppStore
.getState()
.tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId)?.agentLaunchPane
if (launchPane?.leafId === leafId && !launchPane.outcome) {
// The user closed a launch pane whose agent is still starting; main stops that launch.
noteAgentLaunchPaneClosedByUser(tabId, leafId)
}
commitTerminalSurfaceClose(worktreeId, { kind: 'pane', tabId, leafId })
retireUnboundIpcTerminalPane({
getState: useAppStore.getState,
tabId,
leafId,
transport: paneTransportsRef.current.get(paneId),
getTransports: () => paneTransportsRef.current
})
useAppStore.getState().setCacheTimerStartedAt(makePaneKey(tabId, leafId), null)
useAppStore.getState().dropAgentStatus(makePaneKey(tabId, leafId), { paneRemoved: true })
}
setTerminalErrorsByPaneId((current) => clearPaneTerminalError(current, paneId))
if (leafId) {
retireUnboundRuntimeTerminalPane({
getState: useAppStore.getState,
tabId,
leafId,
transport: paneTransportsRef.current.get(paneId),
getTransports: () => paneTransportsRef.current
})
syncPanePtyLayoutBindingForLeaf?.(leafId, null, paneId)
} else {
syncPanePtyLayoutBinding(paneId, null)
}
manager.closePane(paneId)
return
}
const ptyId = paneTransportsRef.current.get(paneId)?.getPtyId() ?? null
closeWebRuntimeTerminal(ptyId)
clearSessionRestoredBannerForPane(paneId)
const leafId = manager.getLeafId(paneId)
if (leafId) {
const launchPane = useAppStore
.getState()
.tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId)?.agentLaunchPane
if (launchPane?.leafId === leafId && !launchPane.outcome) {
// The user closed a launch pane whose agent is still starting; main stops that launch.
noteAgentLaunchPaneClosedByUser(tabId, leafId)
}
if (!committedLeafId) {
commitTerminalSurfaceClose(worktreeId, { kind: 'pane', tabId, leafId })
}
retireUnboundIpcTerminalPane({
getState: useAppStore.getState,
tabId,
leafId,
transport: paneTransportsRef.current.get(paneId),
getTransports: () => paneTransportsRef.current
})
useAppStore.getState().setCacheTimerStartedAt(makePaneKey(tabId, leafId), null)
useAppStore.getState().dropAgentStatus(makePaneKey(tabId, leafId), { paneRemoved: true })
}
setTerminalErrorsByPaneId((current) => clearPaneTerminalError(current, paneId))
if (leafId) {
retireUnboundRuntimeTerminalPane({
getState: useAppStore.getState,
tabId,
leafId,
transport: paneTransportsRef.current.get(paneId),
getTransports: () => paneTransportsRef.current
})
syncPanePtyLayoutBindingForLeaf?.(leafId, null, paneId)
} else {
syncPanePtyLayoutBinding(paneId, null)
}
manager.closePane(paneId)
},
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract.
[
@@ -96,6 +103,40 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr
worktreeId
]
)
const executeClosePane = useCallback(
(paneId: number) => {
const manager = managerRef.current
if (!manager) {
return
}
if (manager.getPanes().length <= 1) {
onCloseTab()
return
}
const leafId = manager.getLeafId(paneId)
// Switch point: main closes the pane first; the window then removes it.
if (
ORCA_LAYOUT_OWNER_IS_MAIN &&
leafId &&
sendPaneCommandToLayoutOwner(
worktreeId,
{ type: 'closePane', tabId, leafId },
{
apply: () => closePaneLocally(paneId, leafId),
asToday: () => closePaneLocally(paneId),
// An earlier close in flight took the sibling: once it lands here too, the tab closes.
refused: (code) =>
code === 'last_pane' && managerRef.current?.getPanes().length === 1 && onCloseTab()
}
)
) {
return
}
closePaneLocally(paneId)
},
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract.
[closePaneLocally, onCloseTab, tabId, worktreeId]
)
const getCloseDialogCopyKind = useCallback(
(paneId: number) => resolveLeafCloseCopyKind(tabId, managerRef.current?.getLeafId(paneId)),
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract.
@@ -23,12 +23,11 @@ export function useTerminalPaneContextActions(controller: TerminalPaneCloseContr
paneCwdRef,
paneTitlesRef,
paneTransportsRef,
persistLayoutSnapshot,
quickCommandGroupId,
quickCommandRepoId,
projectHostSetupProjection,
removePaneTitle,
removedTitleLeafIdsRef,
changePaneTitle,
renameBlurCommitEnabledRef,
renameEnableBlurFrameRef,
renameFocusFrameRef,
@@ -42,7 +41,6 @@ export function useTerminalPaneContextActions(controller: TerminalPaneCloseContr
rightClickToPaste,
setAgentSessionContinuation,
setAgentSessionFork,
setPaneTitles,
setRenamingPaneId,
setTerminalError,
tabId,
@@ -64,17 +62,11 @@ export function useTerminalPaneContextActions(controller: TerminalPaneCloseContr
setRenamingPaneId(null)
return
}
setPaneTitles((previous) => ({ ...previous, [renamingPaneId]: trimmed }))
paneTitlesRef.current = { ...paneTitlesRef.current, [renamingPaneId]: trimmed }
const leafId = managerRef.current?.getPanes().find((pane) => pane.id === renamingPaneId)?.leafId
if (leafId) {
removedTitleLeafIdsRef.current.delete(leafId)
}
closeRenameSession()
setRenamingPaneId(null)
persistLayoutSnapshot('gesture')
changePaneTitle(renamingPaneId, trimmed)
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract.
}, [closeRenameSession, renamingPaneId, renameValue, removePaneTitle, persistLayoutSnapshot])
}, [closeRenameSession, renamingPaneId, renameValue, removePaneTitle, changePaneTitle])
const handleRenameCancel = useCallback(() => {
renameSubmittedRef.current = true
closeRenameSession()
@@ -27,6 +27,7 @@ import { useAppStore } from '@/store'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { resolvePaneAgentSessionId } from './pane-agent-session-id'
import { resetTerminalInputModes } from './terminal-input-mode-reset'
import { equalizeTerminalPanes } from './terminal-pane-layout-owner-commands'
type UseTerminalPaneContextMenuDeps = {
managerRef: React.RefObject<PaneManager | null>
@@ -196,8 +197,9 @@ export function useTerminalPaneContextMenu({
if (!pane || !manager) {
return
}
manager.equalizePaneSizes()
pane.terminal.focus()
equalizeTerminalPanes({ worktreeId, tabId, getManager: () => managerRef.current }, () =>
pane.terminal.focus()
)
}
const onClosePane = (): void => {
@@ -13,6 +13,8 @@ import {
} from '@/lib/terminal-pane-title-sanitization'
import type { TerminalPaneStartupController } from './use-terminal-pane-startup-actions'
import type { PaneLayoutEditIntent } from '../../../../shared/rpc-contract/session-tabs-schemas-params'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { sendPaneCommandToLayoutOwner } from './terminal-pane-layout-owner-commands'
export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartupController) {
const {
@@ -157,7 +159,7 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup
[paneTransportsRef, persistLayoutSnapshot]
)
const removePaneTitle = useCallback(
const removePaneTitleLocally = useCallback(
(paneId: number) => {
setPaneTitles((previous) => {
if (!(paneId in previous)) {
@@ -182,6 +184,46 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract.
[persistLayoutSnapshot]
)
const setPaneTitleLocally = useCallback(
(paneId: number, title: string) => {
setPaneTitles((previous) => ({ ...previous, [paneId]: title }))
paneTitlesRef.current = { ...paneTitlesRef.current, [paneId]: title }
const leafId = managerRef.current?.getPanes().find((pane) => pane.id === paneId)?.leafId
if (leafId) {
removedTitleLeafIdsRef.current.delete(leafId)
}
persistLayoutSnapshot('gesture')
},
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Refs are stable.
[persistLayoutSnapshot]
)
/** A user's title for a pane, trimmed and non-empty; null clears it. */
const changePaneTitle = useCallback(
(paneId: number, title: string | null) => {
const showTitle = (): void =>
title === null ? removePaneTitleLocally(paneId) : setPaneTitleLocally(paneId, title)
const leafId = managerRef.current?.getPanes().find((pane) => pane.id === paneId)?.leafId
// Switch point: main renames the pane first; the window then shows the title.
if (
ORCA_LAYOUT_OWNER_IS_MAIN &&
leafId &&
sendPaneCommandToLayoutOwner(
worktreeId,
{ type: 'renamePane', tabId, leafId, title },
{ apply: showTitle, asToday: showTitle }
)
) {
return
}
showTitle()
},
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Refs are stable.
[removePaneTitleLocally, setPaneTitleLocally, tabId, worktreeId]
)
const removePaneTitle = useCallback(
(paneId: number) => changePaneTitle(paneId, null),
[changePaneTitle]
)
const handleClearPaneTitleShortcut = useCallback(
(paneId: number) => {
if (!paneTitlesRef.current[paneId]) {
@@ -238,6 +280,7 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup
return {
persistLayoutSnapshot,
clearPaneScrollback,
changePaneTitle,
removePaneTitle,
handleClearPaneTitleShortcut
}
@@ -1,5 +1,5 @@
// Why: serialization drops ratios this close to 0.5, so an apply within it must not write either.
export const SPLIT_RATIO_TOLERANCE = 0.005
export { SPLIT_RATIO_TOLERANCE } from '../../../../shared/workspace-layout/terminal-pane-tree'
/** First child's share of a split, read from the two children's flex-grow. */
export function readSplitRatio(first: HTMLElement, second: HTMLElement): number {
@@ -11,18 +11,43 @@ export const LayoutUnsubscribeParams = z.object({
subscriptionId: z.string().min(1, 'Missing subscriptionId')
})
const SplitPaneCommand = z.object({
type: z.literal('splitPane'),
const OnPane = {
workspace: z.string().min(1),
tabId: z.string().min(1),
leafId: z.string().min(1),
leafId: z.string().min(1)
}
const SplitPaneCommand = z.object({
type: z.literal('splitPane'),
...OnPane,
direction: z.enum(['horizontal', 'vertical'])
})
/** Main marks a close as the user's; the window cannot send that mark. */
const ClosePaneCommand = z.object({ type: z.literal('closePane'), ...OnPane })
/** A title is trimmed and non-empty; null clears it. */
const RenamePaneCommand = z.object({
type: z.literal('renamePane'),
...OnPane,
title: z.union([z.string().trim().min(1), z.null()])
})
const EqualizePanesCommand = z.object({
type: z.literal('equalizePanes'),
workspace: OnPane.workspace,
tabId: OnPane.tabId
})
/**
* A window's layout command (`layout.apply`, registered only in a layout-owner build). The window
* names what it acts on; main mints every new id, so no new id is accepted here.
*/
export const LayoutApplyParams = z.object({
command: z.discriminatedUnion('type', [SplitPaneCommand])
command: z.discriminatedUnion('type', [
SplitPaneCommand,
ClosePaneCommand,
RenamePaneCommand,
EqualizePanesCommand
])
})
@@ -120,6 +120,9 @@ function equalizeWeight(
: 1
}
/** A ratio this close to an even split is saved as none, as the desktop window saves it. */
export const SPLIT_RATIO_TOLERANCE = 0.005
/** Same-axis panes get equal shares, so three side by side become thirds, as the window does. */
export function equalizeLayout(node: TerminalPaneLayoutNode): TerminalPaneLayoutNode {
if (node.type === 'leaf') {
@@ -127,9 +130,13 @@ export function equalizeLayout(node: TerminalPaneLayoutNode): TerminalPaneLayout
}
const first = equalizeWeight(node.first, node.direction)
const second = equalizeWeight(node.second, node.direction)
const { ratio: _ratio, ...split } = node
const ratio = first / (first + second)
return {
...node,
ratio: first / (first + second),
...split,
...(Math.abs(ratio - 0.5) > SPLIT_RATIO_TOLERANCE
? { ratio: Math.round(ratio * 1000) / 1000 }
: {}),
first: equalizeLayout(node.first),
second: equalizeLayout(node.second)
}
@@ -40,7 +40,16 @@ export type LayoutCommand =
/** The new pane's id, when the runtime minted it before applying. */
newLeafId?: string
})
| (On & { type: 'closePane'; tabId: string; leafId: string })
| (On & {
type: 'closePane'
tabId: string
leafId: string
/**
* A user's close of one pane of a split, as the desktop's pane close: the tab's last pane is
* refused (closing it is a tab close).
*/
userPaneClose?: true
})
| (On & {
type: 'closeTabs'
tabIds: string[]
@@ -63,7 +63,11 @@ export function closePane(model: WorkspaceLayoutModel, command: CommandOf<'close
if (!tab || !layoutContainsLeafId(tab.panes.root, command.leafId)) {
return applied(model, { alreadyClosed: true })
}
const ptyId = model.workspaces[command.workspace]!.leaves?.[command.leafId]?.ptyId
const leaf = model.workspaces[command.workspace]!.leaves?.[command.leafId]
if (command.userPaneClose && tab.panes.root?.type === 'leaf') {
return refuse('last_pane')
}
const ptyId = leaf?.ptyId
const retired = retireTerminalPane(
model,
{ workspaceKey: command.workspace, tab },
@@ -55,8 +55,24 @@ function commandsFor(model: WorkspaceLayoutModel): LayoutCommand[] {
direction: 'vertical'
})
}
if (first) {
commands.push({
type: 'renamePane',
workspace,
tabId: tab.id,
leafId: first,
title: 'Pane'
})
}
if (second) {
commands.push({ type: 'closePane', workspace, tabId: tab.id, leafId: second })
commands.push({
type: 'closePane',
workspace,
tabId: tab.id,
leafId: second,
userPaneClose: true
})
commands.push({ type: 'equalizePanes', workspace, tabId: tab.id })
}
}
@@ -23,7 +23,7 @@ const MAP_RECORD_DEPTH = 2
/**
* `stored` with the records the command touched (those differing between `before` and `after`)
* written as `after` has them. A touched record the load supplied is written whole; every
* untouched record stays as stored.
* untouched record stays as stored. `stored` itself when the command changed nothing.
*/
export function patchStoredSession(
stored: WorkspaceSessionState,
@@ -33,8 +33,12 @@ export function patchStoredSession(
const storedFields = toJson(stored)
const beforeFields = toJson(before)
const afterFields = toJson(after)
const changed = changedKeys(beforeFields, afterFields)
if (changed.length === 0) {
return stored
}
const patched: Json = { ...storedFields }
for (const field of changedKeys(beforeFields, afterFields)) {
for (const field of changed) {
const idOf = LIST_FIELDS[field]
// The command dropped the field: stored records the load never read go with it.
if (afterFields[field] === undefined) {