From a40ae0ce5adece5211086a776c3daaca308a02fd Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:37:07 -0700 Subject: [PATCH] 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 --- .../loading-store/pty-binding-persistence.ts | 10 - .../loading-store/store-domain-composition.ts | 5 + .../layout-command-persistence.ts | 42 ++++ .../pane-close-remote-session.ts | 33 +++ .../terminal-layout-apply.test.ts | 2 +- .../terminal-layout-pane-ops.test.ts | 204 ++++++++++++++++++ .../terminal-pane-admission.ts | 10 +- .../terminal-topology-commit.ts | 43 +++- src/main/runtime/orca-runtime.ts | 27 +-- .../methods/workspace-layout-apply.test.ts | 19 +- src/main/runtime/runtime-layout-apply.test.ts | 57 +++++ src/main/runtime/runtime-layout-apply.ts | 40 ++++ .../layout-owner-pane-close.test.ts | 112 ++++++++++ .../terminal-keyboard-action-dispatch.ts | 6 +- .../terminal-layout-gesture-persist.test.tsx | 51 ++++- ...erminal-pane-layout-owner-commands.test.ts | 150 +++++++++++++ .../terminal-pane-layout-owner-commands.ts | 82 +++++++ ...al-pane-split-through-layout-owner.test.ts | 80 ------- ...erminal-pane-split-through-layout-owner.ts | 43 ---- ...inal-pane-split-with-inherited-cwd.test.ts | 31 +-- .../terminal-pane-split-with-inherited-cwd.ts | 15 +- .../use-terminal-pane-close-actions.ts | 125 +++++++---- .../use-terminal-pane-context-actions.ts | 14 +- .../use-terminal-pane-context-menu.ts | 6 +- .../use-terminal-pane-layout-persistence.ts | 45 +++- .../src/lib/pane-manager/pane-split-ratio.ts | 2 +- .../rpc-contract/workspace-layout-params.ts | 33 ++- .../workspace-layout/terminal-pane-tree.ts | 11 +- .../workspace-layout-command-types.ts | 11 +- .../workspace-layout-pane-commands.ts | 6 +- .../workspace-layout-record-patch.test.ts | 16 ++ .../workspace-layout-record-patch.ts | 8 +- 32 files changed, 1085 insertions(+), 254 deletions(-) create mode 100644 src/main/persistence/terminal-topology/layout-command-persistence.ts create mode 100644 src/main/persistence/terminal-topology/pane-close-remote-session.ts create mode 100644 src/main/persistence/terminal-topology/terminal-layout-pane-ops.test.ts create mode 100644 src/main/runtime/runtime-layout-apply.test.ts create mode 100644 src/main/runtime/runtime-layout-apply.ts create mode 100644 src/renderer/src/components/terminal-pane/layout-owner-pane-close.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.ts delete mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-split-through-layout-owner.test.ts delete mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-split-through-layout-owner.ts diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index edf621508a3..c274a883acc 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -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 { - return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation( - applyLayout(resolveHostId(hostId), command, this.topology()) - ) - } } function writePtyBinding( diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index fbb1bc36dce..d07b26f15e3 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -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, diff --git a/src/main/persistence/terminal-topology/layout-command-persistence.ts b/src/main/persistence/terminal-topology/layout-command-persistence.ts new file mode 100644 index 00000000000..ff5648fd187 --- /dev/null +++ b/src/main/persistence/terminal-topology/layout-command-persistence.ts @@ -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 { + const context = this[layoutCommandPersistenceContext] + const resolved = hostIds.length > 0 ? hostIds.map(resolveHostId) : [resolveHostId()] + return context.runtime.runDurableMutation( + applyLayout(resolved, command, topologyCommitContext(context)) + ) + } +} diff --git a/src/main/persistence/terminal-topology/pane-close-remote-session.ts b/src/main/persistence/terminal-topology/pane-close-remote-session.ts new file mode 100644 index 00000000000..f0af92bba02 --- /dev/null +++ b/src/main/persistence/terminal-topology/pane-close-remote-session.ts @@ -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 } +} diff --git a/src/main/persistence/terminal-topology/terminal-layout-apply.test.ts b/src/main/persistence/terminal-topology/terminal-layout-apply.test.ts index a2ee53f8407..07d9b5bcd3a 100644 --- a/src/main/persistence/terminal-topology/terminal-layout-apply.test.ts +++ b/src/main/persistence/terminal-topology/terminal-layout-apply.test.ts @@ -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 diff --git a/src/main/persistence/terminal-topology/terminal-layout-pane-ops.test.ts b/src/main/persistence/terminal-topology/terminal-layout-pane-ops.test.ts new file mode 100644 index 00000000000..b38d48c4ff1 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-layout-pane-ops.test.ts @@ -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[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 } + }) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-pane-admission.ts b/src/main/persistence/terminal-topology/terminal-pane-admission.ts index 02bf656cd9d..fc4f46213ee 100644 --- a/src/main/persistence/terminal-topology/terminal-pane-admission.ts +++ b/src/main/persistence/terminal-topology/terminal-pane-admission.ts @@ -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 { 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 { const applied = applyLayoutCommand(loaded.layout, command, { mintId: randomUUID, diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index 30cf447302d..af0388fb6c7 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -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 { 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 { + 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( hostId: ExecutionHostId, change: PaneLayoutChange, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3a2709da850..7639d6ca3d8 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 { - 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 { + 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`). */ diff --git a/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts b/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts index 2e5272f18c6..d46b40dac65 100644 --- a/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts +++ b/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts @@ -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 () => { diff --git a/src/main/runtime/runtime-layout-apply.test.ts b/src/main/runtime/runtime-layout-apply.test.ts new file mode 100644 index 00000000000..7e649a137fd --- /dev/null +++ b/src/main/runtime/runtime-layout-apply.test.ts @@ -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() + }) +}) diff --git a/src/main/runtime/runtime-layout-apply.ts b/src/main/runtime/runtime-layout-apply.ts new file mode 100644 index 00000000000..8cb7db232a0 --- /dev/null +++ b/src/main/runtime/runtime-layout-apply.ts @@ -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 { + 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') + ) +} diff --git a/src/renderer/src/components/terminal-pane/layout-owner-pane-close.test.ts b/src/renderer/src/components/terminal-pane/layout-owner-pane-close.test.ts new file mode 100644 index 00000000000..06626042c93 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/layout-owner-pane-close.test.ts @@ -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() +})) + +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() +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-action-dispatch.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-action-dispatch.ts index b6d1244210f..8981d60f1d3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-keyboard-action-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-action-dispatch.ts @@ -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> @@ -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') { diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx b/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx index 10f1c3ec71f..6940f1297e6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx @@ -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() +})) + +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']) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.test.ts new file mode 100644 index 00000000000..b3f1d98cc12 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.test.ts @@ -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 + 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()) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.ts b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.ts new file mode 100644 index 00000000000..bc732834e9a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-layout-owner-commands.ts @@ -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({ 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() +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-through-layout-owner.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-through-layout-owner.test.ts deleted file mode 100644 index f56dca214ed..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-pane-split-through-layout-owner.test.ts +++ /dev/null @@ -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; 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() - }) -}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-through-layout-owner.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-through-layout-owner.ts deleted file mode 100644 index 41e9c8ea502..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-pane-split-through-layout-owner.ts +++ /dev/null @@ -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({ 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 -} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts index 9b179450563..a2a0b21b876 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts @@ -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): 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({ diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts index 16c179c91ca..198b115dc66 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts index b3acc4ec6e3..bc0a493a1e9 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts @@ -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. diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-actions.ts index b800c4c3b80..b70e0f0ec75 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-actions.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 7d086992e21..3289186e9b8 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -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 @@ -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 => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts index cf9182128fa..982bfe66e70 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts @@ -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 } diff --git a/src/renderer/src/lib/pane-manager/pane-split-ratio.ts b/src/renderer/src/lib/pane-manager/pane-split-ratio.ts index fb8bf9b6cf6..8fe032316b3 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-ratio.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-ratio.ts @@ -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 { diff --git a/src/shared/rpc-contract/workspace-layout-params.ts b/src/shared/rpc-contract/workspace-layout-params.ts index 947554e6e0a..28a87083bc2 100644 --- a/src/shared/rpc-contract/workspace-layout-params.ts +++ b/src/shared/rpc-contract/workspace-layout-params.ts @@ -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 + ]) }) diff --git a/src/shared/workspace-layout/terminal-pane-tree.ts b/src/shared/workspace-layout/terminal-pane-tree.ts index 98deb286a9f..dc42eb0154e 100644 --- a/src/shared/workspace-layout/terminal-pane-tree.ts +++ b/src/shared/workspace-layout/terminal-pane-tree.ts @@ -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) } diff --git a/src/shared/workspace-layout/workspace-layout-command-types.ts b/src/shared/workspace-layout/workspace-layout-command-types.ts index 4498f319315..a9461bc09d4 100644 --- a/src/shared/workspace-layout/workspace-layout-command-types.ts +++ b/src/shared/workspace-layout/workspace-layout-command-types.ts @@ -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[] diff --git a/src/shared/workspace-layout/workspace-layout-pane-commands.ts b/src/shared/workspace-layout/workspace-layout-pane-commands.ts index 1f7002cca71..434bfd29b06 100644 --- a/src/shared/workspace-layout/workspace-layout-pane-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-pane-commands.ts @@ -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 }, diff --git a/src/shared/workspace-layout/workspace-layout-record-patch.test.ts b/src/shared/workspace-layout/workspace-layout-record-patch.test.ts index 036776531ac..4467c3dd5bb 100644 --- a/src/shared/workspace-layout/workspace-layout-record-patch.test.ts +++ b/src/shared/workspace-layout/workspace-layout-record-patch.test.ts @@ -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 }) } } diff --git a/src/shared/workspace-layout/workspace-layout-record-patch.ts b/src/shared/workspace-layout/workspace-layout-record-patch.ts index c9afdce83ca..61934a7f50d 100644 --- a/src/shared/workspace-layout/workspace-layout-record-patch.ts +++ b/src/shared/workspace-layout/workspace-layout-record-patch.ts @@ -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) {