diff --git a/src/main/ipc/pty/ipc/leaf-move.ts b/src/main/ipc/pty/ipc/leaf-move.ts index a8dcc896973..c5d106c7ad3 100644 --- a/src/main/ipc/pty/ipc/leaf-move.ts +++ b/src/main/ipc/pty/ipc/leaf-move.ts @@ -1,11 +1,9 @@ import { getPtyIpc } from '../../pty-host-bindings' import type { Store } from '../../../persistence' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' -import { agentHookServer } from '../../../agent-hooks/server' -import { forgetFailedAgentLaunches } from '../../../agent-launch/agent-launch-pane-attachment' +import { rekeyMovedPaneOwners } from '../../../runtime/moved-pane-owner-rekey' import { isTerminalLeafMoveRequest, - terminalLeafMovePaneKeys, type TerminalLeafMoveRequest, type TerminalLeafMoveResult } from '../../../../shared/terminal-leaf-move' @@ -22,32 +20,7 @@ export async function commitLeafMoveAndRekey( if (result.status !== 'moved') { return result } - const { from: fromPaneKey, to: toPaneKey } = terminalLeafMovePaneKeys(request) - forgetFailedAgentLaunches(request.worktreeId, { - kind: 'pane', - tabId: request.sourceTabId, - leafId: request.leafId - }) - try { - // The process keeps the pane key baked into its env, so status must alias old to new. - agentHookServer.transferPaneAuthority( - fromPaneKey, - toPaneKey, - result.ptyId ?? undefined, - Date.now(), - { authorityVerified: true } - ) - } catch (error) { - console.warn('[pty] moved pane kept its old agent-status key:', error) - } - try { - deps.runtime?.getExistingOrchestrationDb()?.rekeyWorkerTerminalResourcePaneKey({ - fromPaneKey, - toPaneKey - }) - } catch (error) { - console.warn('[pty] moved pane kept its old orchestration resource key:', error) - } + rekeyMovedPaneOwners(deps.runtime, { ...request, ptyId: result.ptyId }) return result } diff --git a/src/main/persistence/terminal-topology/pane-close-remote-session.ts b/src/main/persistence/terminal-topology/pane-remote-session-follow.ts similarity index 54% rename from src/main/persistence/terminal-topology/pane-close-remote-session.ts rename to src/main/persistence/terminal-topology/pane-remote-session-follow.ts index f0af92bba02..dc36b075c30 100644 --- a/src/main/persistence/terminal-topology/pane-close-remote-session.ts +++ b/src/main/persistence/terminal-topology/pane-remote-session-follow.ts @@ -31,3 +31,32 @@ export function followRemoteSessionAfterPaneClose( } return { ...written, remoteSessionIdsByTabId: next } } + +/** A window's drag-out: the source tab's remote session id follows the moved pane's terminal. */ +export function followRemoteSessionAfterPaneDetach( + command: LayoutCommand, + written: WorkspaceSessionState +): WorkspaceSessionState { + const targetTabId = command.type === 'movePaneToNewTab' && command.windowDetach?.tabId + const remoteSessionIds = written.remoteSessionIdsByTabId + if (!targetTabId || !remoteSessionIds) { + return written + } + const sourceTabId = + written.unifiedTabs?.[command.workspace]?.find((entry) => entry.id === command.tabId) + ?.entityId ?? command.tabId + const ptyId = written.terminalLayoutsByTabId[targetTabId]?.ptyIdsByLeafId?.[command.leafId] + if (!ptyId || remoteSessionIds[sourceTabId] !== ptyId) { + return written + } + const remaining = Object.values( + written.terminalLayoutsByTabId[sourceTabId]?.ptyIdsByLeafId ?? {} + )[0] + const next = { ...remoteSessionIds, [targetTabId]: ptyId } + if (remaining) { + next[sourceTabId] = remaining + } else { + delete next[sourceTabId] + } + return { ...written, remoteSessionIdsByTabId: next } +} diff --git a/src/main/persistence/terminal-topology/restorable-write.ts b/src/main/persistence/terminal-topology/restorable-write.ts new file mode 100644 index 00000000000..5fcbd0edc63 --- /dev/null +++ b/src/main/persistence/terminal-topology/restorable-write.ts @@ -0,0 +1,10 @@ +/** Writes `next`, returning a restore that puts the prior value back unless a later write replaced it. */ +export function writeRestorable(read: () => V, write: (value: V) => void, next: V): () => void { + const prior = read() + write(next) + return () => { + if (read() === next) { + write(prior) + } + } +} diff --git a/src/main/persistence/terminal-topology/terminal-leaf-move.ts b/src/main/persistence/terminal-topology/terminal-leaf-move.ts index 5248ca7c0d9..5a542b8f434 100644 --- a/src/main/persistence/terminal-topology/terminal-leaf-move.ts +++ b/src/main/persistence/terminal-topology/terminal-leaf-move.ts @@ -10,6 +10,8 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st import { retireLeavesFromTerminalLayout } from '../../runtime/mobile-session-terminal-retirement' import { createMinimalPersistedTerminalTab } from '../restoring-sessions/session-owner-fields' import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' +import { writeRestorable } from './restorable-write' +import type { TerminalTopologyCommitContext } from './terminal-topology-commit' import { advanceTerminalTopologyRevision, isTerminalOwnerPartition, @@ -52,6 +54,21 @@ function hasTabId(session: WorkspaceSessionState, tabId: string): boolean { ) } +/** A tab other than the source holds the leaf; a layout left behind by a removed row owns nothing. */ +export function leafHeldByOtherTab( + session: WorkspaceSessionState, + sourceTabId: string, + leafId: string +): boolean { + const tabIds = liveTabIds(session) + return Object.entries(session.terminalLayoutsByTabId ?? {}).some( + ([tabId, layout]) => + tabId !== sourceTabId && + tabIds.has(tabId) && + layoutContainsLeafId(layout?.root ?? null, leafId) + ) +} + type SourceHolder = TerminalSessionPartition & { sourceTab: TerminalTab sourceLayout: TerminalLayoutSnapshot @@ -154,17 +171,7 @@ export function planTerminalLeafMove( if (partitions.some(({ session }) => hasTabId(session, request.targetTabId))) { return refuse('target_tab_exists') } - // A layout left behind by a removed tab row owns nothing. - const leafElsewhere = owners.some(({ session }) => { - const tabIds = liveTabIds(session) - return Object.entries(session.terminalLayoutsByTabId ?? {}).some( - ([tabId, layout]) => - tabId !== sourceTabId && - tabIds.has(tabId) && - layoutContainsLeafId(layout?.root ?? null, leafId) - ) - }) - if (leafElsewhere) { + if (owners.some(({ session }) => leafHeldByOtherTab(session, sourceTabId, leafId))) { return refuse('leaf_in_other_tab') } const holders = owners.flatMap(({ hostId, session }) => { @@ -232,3 +239,34 @@ export function rekeyMovedLeafProfileRecords( : {}) } } + +/** Pane-keyed UI marks and SSH leases follow a moved leaf; returns their restores. */ +export function writeMovedLeafProfileRecords( + request: TerminalLeafMoveRequest, + context: TerminalTopologyCommitContext +): (() => void)[] { + const { state } = context + const restores: (() => void)[] = [] + const rekeyed = rekeyMovedLeafProfileRecords(state, request) + if (rekeyed.ui) { + restores.push( + writeRestorable( + () => state.ui, + (ui) => (state.ui = ui), + rekeyed.ui + ) + ) + context.markDirty('ui') + } + if (rekeyed.sshRemotePtyLeases) { + restores.push( + writeRestorable( + () => state.sshRemotePtyLeases, + (leases) => (state.sshRemotePtyLeases = leases), + rekeyed.sshRemotePtyLeases + ) + ) + context.markDirty('sshRemotePtyLeases') + } + return restores +} diff --git a/src/main/persistence/terminal-topology/terminal-pane-admission.ts b/src/main/persistence/terminal-topology/terminal-pane-admission.ts index 6e994557d86..67ddf01a87d 100644 --- a/src/main/persistence/terminal-topology/terminal-pane-admission.ts +++ b/src/main/persistence/terminal-topology/terminal-pane-admission.ts @@ -21,7 +21,10 @@ import { } 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' +import { + followRemoteSessionAfterPaneClose, + followRemoteSessionAfterPaneDetach +} from './pane-remote-session-follow' import { pruneClosedTerminalTabTombstones } from '../../../shared/closed-terminal-tab-tombstones' /** A new tab, or a split of an existing pane, with the ids its terminal will run under. */ @@ -77,7 +80,10 @@ export function applyCommandToSession( const patched = patchStoredSession(session, saveWorkspaceLayout(loaded), after) return patched === session ? null - : followRemoteSessionAfterPaneClose(command, session, prunedOnRecord(command, patched)) + : followRemoteSessionAfterPaneDetach( + command, + followRemoteSessionAfterPaneClose(command, session, prunedOnRecord(command, patched)) + ) }) } diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index af0388fb6c7..c452bd18ae4 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -29,9 +29,11 @@ import type { StoreRuntimeState } from '../loading-store/store-runtime-state' import type { SessionHostPartitionOperations } from '../loading-store/session-host-partitions' -import { planTerminalLeafMove, rekeyMovedLeafProfileRecords } from './terminal-leaf-move' +import { planTerminalLeafMove, writeMovedLeafProfileRecords } from './terminal-leaf-move' +import { writeRestorable } from './restorable-write' import { assignWorkspaceSessionPartition } from './terminal-topology-membership' import { keepWslMoveFrozenSessionsForStore } from '../moving-wsl-projects/wsl-move-session-freeze' +import { resolveWindowPaneDetach, type WindowPaneDetachCommand } from './window-pane-detach' // The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings). Wraps the close and // the pane move; the close transform still lives in runtime/ and other writers move here later. @@ -118,7 +120,14 @@ export function applyLayout( ): () => DurableProfileStateMutation { return traced( 'apply_layout', - () => commitLayoutCommand(hostIds, command, context), + () => + command.type === 'movePaneToNewTab' && command.windowDetach + ? commitWindowPaneDetach( + hostIds, + { ...command, windowDetach: command.windowDetach }, + context + ) + : commitLayoutCommand(hostIds, command, context), (outcome) => (outcome.ok ? undefined : outcome.code) ) } @@ -190,7 +199,6 @@ export function topologyCommitContext({ } } -/** Writes `next`, returning a restore that puts the prior value back unless a later write replaced it. */ // A paused WSL move keeps its local rows as the quiesce left them. function keepFrozen( hostId: ExecutionHostId, @@ -202,16 +210,6 @@ function keepFrozen( : session } -function writeRestorable(read: () => V, write: (value: V) => void, next: V): () => void { - const prior = read() - write(next) - return () => { - if (read() === next) { - write(prior) - } - } -} - function commitLeafMove( request: TerminalLeafMoveRequest, context: TerminalTopologyCommitContext @@ -231,33 +229,50 @@ function commitLeafMove( keepFrozen(hostId, session, context) ) ) - const rekeyed = rekeyMovedLeafProfileRecords(state, request) - if (rekeyed.ui) { - restores.push( - writeRestorable( - () => state.ui, - (ui) => (state.ui = ui), - rekeyed.ui - ) - ) - context.markDirty('ui') - } - if (rekeyed.sshRemotePtyLeases) { - restores.push( - writeRestorable( - () => state.sshRemotePtyLeases, - (leases) => (state.sshRemotePtyLeases = leases), - rekeyed.sshRemotePtyLeases - ) - ) - context.markDirty('sshRemotePtyLeases') - } + restores.push(...writeMovedLeafProfileRecords(request, context)) return { value: planned.result, rollback: () => restores.forEach((restore) => restore()) } } +/** A window's drag-out: checked across every partition, then moved in each holder as one write. */ +function commitWindowPaneDetach( + hostIds: readonly ExecutionHostId[], + command: WindowPaneDetachCommand, + context: TerminalTopologyCommitContext +): DurableProfileStateMutation { + const resolved = resolveWindowPaneDetach( + context.hostIds().map((hostId) => ({ hostId, session: context.getSession(hostId) })), + hostIds, + command + ) + if (resolved && 'code' in resolved) { + return { value: { ok: false, code: resolved.code }, persist: false } + } + const committed = commitLayoutCommand(hostIds, resolved?.command ?? command, context) + if (!resolved || !committed.value.ok || committed.persist === false) { + return committed + } + const restores = writeMovedLeafProfileRecords( + { + worktreeId: command.workspace, + sourceTabId: resolved.sourceEntityId, + targetTabId: resolved.command.windowDetach.tabId, + leafId: command.leafId, + ptyId: resolved.command.windowDetach.ptyId + }, + context + ) + return { + value: committed.value, + rollback: () => { + restores.forEach((restore) => restore()) + committed.rollback?.() + } + } +} + function commitLayoutCommand( hostIds: readonly ExecutionHostId[], command: LayoutCommand, diff --git a/src/main/persistence/terminal-topology/terminal-window-pane-detach.test.ts b/src/main/persistence/terminal-topology/terminal-window-pane-detach.test.ts new file mode 100644 index 00000000000..6cd090871b6 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-window-pane-detach.test.ts @@ -0,0 +1,310 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { toSshExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { makeRepo } from '../../persistence-test-harness' +import type { Store } from '../loading-store/store' +import { + closeMoveTestStores, + FROM, + LEFT, + MOVED, + newDataFile, + openStore, + ownersOf, + seedSplitSource, + sleeping, + SOURCE, + TARGET, + tabsHoldingLeaf, + TO, + WT +} from './terminal-leaf-move-fixture' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString() + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +afterEach(closeMoveTestStores) + +/** The window's drag-out as `layout.apply` sends it, with the tab id pinned for the assertions. */ +function detach( + store: Store, + ptyId: string | null, + hostIds: ExecutionHostId[] = ['local'] +): ReturnType { + return store.applyLayoutCommand( + { + type: 'movePaneToNewTab', + workspace: WT, + tabId: SOURCE, + leafId: MOVED, + windowDetach: { ptyId, tabId: TARGET } + }, + hostIds + ) +} + +// The desktop's move rules (terminal-leaf-move.test.ts), through layout.apply's pane move. +describe('a window drag-out through layout.apply', () => { + it('moves the leaf and its binding in one write and re-keys pane-keyed records', async () => { + const store = openStore(newDataFile()) + await seedSplitSource(store) + store.setWorkspaceSession({ + ...store.getWorkspaceSession(), + sleepingAgentSessionsByPaneKey: { [FROM]: sleeping(FROM, SOURCE) } + }) + store.updateUI({ + acknowledgedAgentsByPaneKey: { [FROM]: 10 }, + activityClearedAtByPaneKey: { [FROM]: 11 }, + manuallyUnreadTurnsByPaneKey: { [FROM]: 12 } + }) + + await expect(detach(store, 'pty-agent')).resolves.toEqual({ + ok: true, + result: { tabId: TARGET, ptyId: 'pty-agent' } + }) + + const session = store.getWorkspaceSession() + expect(session.terminalLayoutsByTabId[SOURCE]).toMatchObject({ + root: { type: 'leaf', leafId: LEFT }, + ptyIdsByLeafId: { [LEFT]: 'pty-left' } + }) + expect(session.terminalLayoutsByTabId[TARGET]).toMatchObject({ + root: { type: 'leaf', leafId: MOVED }, + ptyIdsByLeafId: { [MOVED]: 'pty-agent' } + }) + expect(session.tabsByWorktree[WT]?.map((tab) => [tab.id, tab.ptyId])).toEqual([ + [SOURCE, 'pty-left'], + [TARGET, 'pty-agent'] + ]) + expect(session.terminalPtyIncarnationsByPaneKey).toEqual({ + [`${SOURCE}:${LEFT}`]: 'inc-left', + [TO]: 'inc-1' + }) + expect(session.sleepingAgentSessionsByPaneKey).toEqual({ + [TO]: expect.objectContaining({ paneKey: TO, tabId: TARGET }) + }) + expect(store.getUI()).toMatchObject({ + acknowledgedAgentsByPaneKey: { [TO]: 10 }, + activityClearedAtByPaneKey: { [TO]: 11 }, + manuallyUnreadTurnsByPaneKey: { [TO]: 12 } + }) + expect(ownersOf(session, 'pty-agent')).toEqual([TO]) + }) + + it('re-keys the SSH lease within the partition that holds the tab', async () => { + const dataFile = newDataFile() + writeFileSync( + dataFile, + JSON.stringify({ + sshRemotePtyLeases: [ + { + targetId: 'ssh-1', + ptyId: 'pty-agent', + worktreeId: WT, + tabId: SOURCE, + leafId: MOVED, + state: 'attached' + } + ] + }) + ) + const store = openStore(dataFile) + const hostId = toSshExecutionHostId('ssh-1') + await seedSplitSource(store, hostId) + + await expect(detach(store, 'pty-agent', [hostId])).resolves.toMatchObject({ ok: true }) + + expect(tabsHoldingLeaf(store.getWorkspaceSession(hostId), MOVED)).toEqual([TARGET]) + expect(store.getWorkspaceSession().tabsByWorktree[WT]).toBeUndefined() + expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ + expect.objectContaining({ ptyId: 'pty-agent', tabId: TARGET, leafId: MOVED }) + ]) + }) + + it('moves the last pane of main’s copy, leaving the tab for the window’s other panes', async () => { + const store = openStore(newDataFile()) + await store.persistPtyBinding({ + worktreeId: WT, + tabId: SOURCE, + leafId: MOVED, + ptyId: 'pty-agent', + incarnationId: 'inc-1' + }) + + await expect(detach(store, 'pty-agent')).resolves.toMatchObject({ ok: true }) + + const session = store.getWorkspaceSession() + expect(tabsHoldingLeaf(session, MOVED)).toEqual([TARGET]) + expect(session.tabsByWorktree[WT]?.find((tab) => tab.id === SOURCE)?.ptyId).toBeNull() + // The window's sibling binds into the source tab later. + expect( + await store.persistPtyBinding({ + worktreeId: WT, + tabId: SOURCE, + leafId: LEFT, + ptyId: 'pty-left', + incarnationId: 'inc-left' + }) + ).toBe(true) + expect(tabsHoldingLeaf(store.getWorkspaceSession(), LEFT)).toEqual([SOURCE]) + }) + + it('refuses a terminal no copy names, and a leaf another tab holds', async () => { + const store = openStore(newDataFile()) + await seedSplitSource(store) + await expect(detach(store, 'pty-other')).resolves.toEqual({ ok: false, code: 'pty_mismatch' }) + const session = store.getWorkspaceSession() + store.setWorkspaceSession({ + ...session, + tabsByWorktree: { + ...session.tabsByWorktree, + [WT]: [ + ...(session.tabsByWorktree[WT] ?? []), + { ...(session.tabsByWorktree[WT] ?? [])[0]!, id: 'tab-earlier-move' } + ] + }, + terminalLayoutsByTabId: { + ...session.terminalLayoutsByTabId, + 'tab-earlier-move': { + root: { type: 'leaf', leafId: MOVED }, + activeLeafId: MOVED, + expandedLeafId: null + } + } + }) + await expect(detach(store, 'pty-agent')).resolves.toEqual({ + ok: false, + code: 'pane_in_other_tab' + }) + expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toContain(SOURCE) + }) + + it('takes the one bound terminal when the window has none', async () => { + const store = openStore(newDataFile()) + await seedSplitSource(store) + await expect(detach(store, null)).resolves.toEqual({ + ok: true, + result: { tabId: TARGET, ptyId: 'pty-agent' } + }) + }) + + it('moves the remote session id with the moved terminal', async () => { + const store = openStore(newDataFile()) + await seedSplitSource(store) + store.setWorkspaceSession({ + ...store.getWorkspaceSession(), + remoteSessionIdsByTabId: { [SOURCE]: 'pty-agent' } + }) + + await detach(store, 'pty-agent') + + expect(store.getWorkspaceSession().remoteSessionIdsByTabId).toEqual({ + [SOURCE]: 'pty-left', + [TARGET]: 'pty-agent' + }) + }) +}) + +describe('a window drag-out of an SSH pane held by both partitions', () => { + it('moves every copy, so the target reattach and the next relay reattach both bind', async () => { + const store = openStore(newDataFile()) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const hostId = toSshExecutionHostId('ssh-1') + await seedSplitSource(store, hostId) + const relay = { worktreeId: WT, leafId: MOVED, ptyId: 'pty-agent', incarnationId: 'inc-1' } + await store.persistPtyBinding({ ...relay, tabId: SOURCE, origin: 'relay_reattach' }) + + await expect(detach(store, 'pty-agent', [hostId, 'local'])).resolves.toMatchObject({ + ok: true + }) + + expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toEqual([TARGET]) + expect(tabsHoldingLeaf(store.getWorkspaceSession(hostId), MOVED)).toEqual([TARGET]) + expect( + await store.persistPtyBinding({ ...relay, tabId: TARGET, origin: 'reattach' }, hostId) + ).toBe(true) + expect( + await store.persistPtyBinding({ + ...relay, + tabId: TARGET, + origin: 'relay_reattach', + mayReviveRetiredSurface: false + }) + ).toBe(true) + }) + + it('follows the live terminal when an SSH respawn bound one partition before the other', async () => { + const store = openStore(newDataFile()) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const hostId = toSshExecutionHostId('ssh-1') + await seedSplitSource(store, hostId) + const relay = { worktreeId: WT, tabId: SOURCE, leafId: MOVED, incarnationId: 'inc-1' } + await store.persistPtyBinding({ ...relay, ptyId: 'pty-agent', origin: 'relay_reattach' }) + await store.persistPtyBinding( + { ...relay, ptyId: 'pty-respawn', incarnationId: 'inc-2' }, + hostId + ) + + await expect(detach(store, 'pty-respawn', [hostId, 'local'])).resolves.toEqual({ + ok: true, + result: { tabId: TARGET, ptyId: 'pty-respawn' } + }) + + for (const session of [store.getWorkspaceSession(), store.getWorkspaceSession(hostId)]) { + expect(tabsHoldingLeaf(session, MOVED)).toEqual([TARGET]) + expect(session.terminalLayoutsByTabId[TARGET]?.ptyIdsByLeafId).toEqual({ + [MOVED]: 'pty-respawn' + }) + } + expect(store.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[TO]).toBe('inc-2') + expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[TO]).toBeUndefined() + }) +}) + +// STA-9259 through layout.apply: move, reattach, a stale pre-move renderer save, restart. +describe('STA-9259 sequence through layout.apply', () => { + it('keeps one owner through reattach, a stale renderer save and a restart', async () => { + const dataFile = newDataFile() + const store = openStore(dataFile) + store.addRepo(makeRepo({ id: 'repo-1', path: '/tmp/move-worktree' })) + await seedSplitSource(store) + const preMoveRendererSnapshot = structuredClone(store.getWorkspaceSession()) + + await detach(store, 'pty-agent') + expect( + await store.persistPtyBinding({ + worktreeId: WT, + tabId: TARGET, + leafId: MOVED, + ptyId: 'pty-agent', + incarnationId: 'inc-1', + expectedBinding: { ptyId: 'pty-agent', incarnationId: 'inc-1' }, + origin: 'reattach' + }) + ).toBe(true) + store.setWorkspaceSession(preMoveRendererSnapshot) + expect(ownersOf(store.getWorkspaceSession(), 'pty-agent')).toEqual([TO]) + expect(tabsHoldingLeaf(store.getWorkspaceSession(), MOVED)).toEqual([TARGET]) + + store.flush() + const restarted = openStore(dataFile) + expect(ownersOf(restarted.getWorkspaceSession(), 'pty-agent')).toEqual([TO]) + expect(tabsHoldingLeaf(restarted.getWorkspaceSession(), MOVED)).toEqual([TARGET]) + }) +}) diff --git a/src/main/persistence/terminal-topology/window-pane-detach.ts b/src/main/persistence/terminal-topology/window-pane-detach.ts new file mode 100644 index 00000000000..337c980127a --- /dev/null +++ b/src/main/persistence/terminal-topology/window-pane-detach.ts @@ -0,0 +1,78 @@ +// A window's drag-out through `layout.apply`, checked across partitions as the desktop's move +// (planTerminalLeafMove) is, before any partition is written. + +import { randomUUID } from 'node:crypto' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { layoutContainsLeafId } from '../../../shared/workspace-layout/terminal-pane-tree' +import { findTerminal } from '../../../shared/workspace-layout/workspace-layout-command-steps' +import type { + CommandOf, + LayoutRefusalCode, + WindowPaneDetach +} from '../../../shared/workspace-layout/workspace-layout-command-types' +import { loadWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-load' +import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' +import { leafHeldByOtherTab } from './terminal-leaf-move' +import { + isTerminalOwnerPartition, + type TerminalSessionPartition +} from './terminal-topology-membership' + +export type WindowPaneDetachCommand = CommandOf<'movePaneToNewTab'> & { + windowDetach: WindowPaneDetach +} + +export type ResolvedWindowPaneDetach = + | { + /** The command every holder applies: one new tab id and one terminal for the pane. */ + command: WindowPaneDetachCommand & { windowDetach: { tabId: string } } + /** The source tab's terminal id (the tab half of the moved pane's old key). */ + sourceEntityId: string + } + | { code: LayoutRefusalCode } + /** No partition holds the pane; the routed apply refuses it. */ + | null + +export function resolveWindowPaneDetach( + partitions: readonly TerminalSessionPartition[], + holderIds: readonly ExecutionHostId[], + command: WindowPaneDetachCommand +): ResolvedWindowPaneDetach { + const { leafId } = command + const holders = partitions.flatMap(({ hostId, session }) => { + if (!holderIds.includes(hostId)) { + return [] + } + const workspace = loadWorkspaceLayout(hostId, session, nameBasedLoadContext()).layout + .workspaces[command.workspace] + const tab = workspace && findTerminal(workspace, command.tabId) + return tab && layoutContainsLeafId(tab.panes.root, leafId) + ? [{ entityId: tab.entityId, ptyId: workspace.leaves?.[leafId]?.ptyId }] + : [] + }) + if (holders.length === 0) { + return null + } + const sourceEntityId = holders[0]!.entityId + if ( + partitions.some( + ({ hostId, session }) => + isTerminalOwnerPartition(hostId) && leafHeldByOtherTab(session, sourceEntityId, leafId) + ) + ) { + return { code: 'pane_in_other_tab' } + } + const bound = new Set(holders.flatMap(({ ptyId }) => (ptyId ? [ptyId] : []))) + // The window's live terminal wins: after an SSH respawn one copy can still name the old one. + const ptyId = command.windowDetach.ptyId ?? (bound.size === 1 ? [...bound][0]! : null) + if (bound.size > 0 && !(ptyId && bound.has(ptyId))) { + return { code: 'pty_mismatch' } + } + return { + command: { + ...command, + windowDetach: { ptyId, tabId: command.windowDetach.tabId ?? randomUUID() } + }, + sourceEntityId + } +} diff --git a/src/main/runtime/moved-pane-owner-rekey.ts b/src/main/runtime/moved-pane-owner-rekey.ts new file mode 100644 index 00000000000..a74af9b0187 --- /dev/null +++ b/src/main/runtime/moved-pane-owner-rekey.ts @@ -0,0 +1,43 @@ +import { agentHookServer } from '../agent-hooks/server' +import { forgetFailedAgentLaunches } from '../agent-launch/agent-launch-pane-attachment' +import { + terminalLeafMovePaneKeys, + type TerminalLeafMoveRequest +} from '../../shared/terminal-leaf-move' +import type { OrcaRuntimeService } from './orca-runtime' + +/** After a committed move of a pane into a new tab: agent-status and orchestration keys follow. */ +export function rekeyMovedPaneOwners( + runtime: Pick | undefined, + /** `ptyId` is the terminal the committed move bound the pane to. */ + request: TerminalLeafMoveRequest +): void { + const { from: fromPaneKey, to: toPaneKey } = terminalLeafMovePaneKeys(request) + forgetFailedAgentLaunches(request.worktreeId, { + kind: 'pane', + tabId: request.sourceTabId, + leafId: request.leafId + }) + try { + // The process keeps the pane key baked into its env, so status must alias old to new. + agentHookServer.transferPaneAuthority( + fromPaneKey, + toPaneKey, + request.ptyId ?? undefined, + Date.now(), + { + authorityVerified: true + } + ) + } catch (error) { + console.warn('[pty] moved pane kept its old agent-status key:', error) + } + try { + runtime?.getExistingOrchestrationDb()?.rekeyWorkerTerminalResourcePaneKey({ + fromPaneKey, + toPaneKey + }) + } catch (error) { + console.warn('[pty] moved pane kept its old orchestration resource key:', error) + } +} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1287c7592aa..da8f7393503 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -28,6 +28,7 @@ import { createReferenceAgentIndex, referenceConnectionHosts } from './runtime-r import type { RuntimeReferenceFindParams } from '../../shared/runtime-reference-contracts' import { getRepoExecutionHostId } from '../../shared/execution-host' import type { LayoutApplyReply } from '../../shared/workspace-layout/workspace-layout-command-types' +import { rekeyMovedPaneOwners } from './moved-pane-owner-rekey' import { applyWindowLayoutCommand, type WindowLayoutCommand } from './runtime-layout-apply' import type { TerminalCreateOptions } from './runtime-terminal-contracts' @@ -180,7 +181,8 @@ class OrcaRuntimeService extends OrcaRuntimeWithMigrationCatalog { const routed = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) return routed ? [routed] : [] }, - command + command, + (move) => rekeyMovedPaneOwners(this, move) ) } 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 5c45218afe9..8e62d98cfef 100644 --- a/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts +++ b/src/main/runtime/rpc/methods/workspace-layout-apply.test.ts @@ -178,6 +178,27 @@ describe('layout.apply', () => { ).toBe(false) }) + it('accepts a drag-out with the window’s terminal, but no tab id from the window', async () => { + const apply = (await registeredMethods(true)).find((method) => method.name === 'layout.apply') + const move = { + type: 'movePaneToNewTab', + workspace: WORKTREE, + tabId: 'tab-1', + leafId: 'leaf-1', + groupId: 'g1', + index: 2 + } + + expect( + apply?.params?.parse({ command: { ...move, windowDetach: { ptyId: 'pty-1', tabId: 'x' } } }) + ).toEqual({ command: { ...move, windowDetach: { ptyId: 'pty-1' } } }) + expect(apply?.params?.safeParse({ command: { ...move, windowDetach: {} } }).success).toBe(false) + expect( + apply?.params?.safeParse({ command: { ...move, windowDetach: { ptyId: null }, index: -1 } }) + .success + ).toBe(false) + }) + it('applies group sizes to the workspace’s routed partition', async () => { const { runtime, applyLayoutCommand } = runtimeHolding('ssh:target-1') const ratios = { diff --git a/src/main/runtime/runtime-layout-apply.test.ts b/src/main/runtime/runtime-layout-apply.test.ts index bc2a229521b..216318aff36 100644 --- a/src/main/runtime/runtime-layout-apply.test.ts +++ b/src/main/runtime/runtime-layout-apply.test.ts @@ -122,6 +122,58 @@ describe('applyWindowLayoutCommand', () => { expect(mocks.markAgentLaunchesClosedByUser).not.toHaveBeenCalled() }) + it('moves a dragged-out pane in every holder, and its pane-keyed owners follow', async () => { + const { store, applyLayoutCommand } = storeWith() + applyLayoutCommand.mockResolvedValueOnce({ + ok: true, + result: { tabId: 'tab-new', ptyId: 'pty-1' } + }) + const session = { unifiedTabs: { 'wt-1': [{ id: 'entry-1', entityId: 'term-1' }] } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the move reads only unifiedTabs. + const withSession = { ...store, getWorkspaceSession: () => session } as unknown as RuntimeStore + const onPaneMoved = vi.fn() + const move: WindowLayoutCommand = { + type: 'movePaneToNewTab', + workspace: 'wt-1', + tabId: 'entry-1', + leafId: 'leaf-1', + groupId: 'g1', + // The window cannot name the new tab. + windowDetach: { ptyId: 'pty-1', tabId: 'tab-window' } + } + + await applyWindowLayoutCommand( + withSession, + () => ['ssh:t', 'runtime:env-1', 'local'], + move, + onPaneMoved + ) + + expect(applyLayoutCommand).toHaveBeenCalledWith({ ...move, windowDetach: { ptyId: 'pty-1' } }, [ + 'ssh:t', + 'local' + ]) + expect(onPaneMoved).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + sourceTabId: 'term-1', + targetTabId: 'tab-new', + leafId: 'leaf-1', + ptyId: 'pty-1' + }) + applyLayoutCommand.mockResolvedValueOnce({ ok: false, code: 'pty_mismatch' }) + await applyWindowLayoutCommand(withSession, () => ['local'], move, onPaneMoved) + expect(onPaneMoved).toHaveBeenCalledTimes(1) + // A committed move still answers ok when re-keying throws. + vi.spyOn(console, 'warn').mockImplementation(() => {}) + applyLayoutCommand.mockResolvedValueOnce({ ok: true, result: { tabId: 'tab-2', ptyId: null } }) + onPaneMoved.mockImplementationOnce(() => { + throw new Error('rekey failed') + }) + await expect( + applyWindowLayoutCommand(withSession, () => ['local'], move, onPaneMoved) + ).resolves.toMatchObject({ ok: true }) + }) + it('refuses a tab a paired server routes, touching nothing', async () => { const { store, applyLayoutCommand } = storeWith() diff --git a/src/main/runtime/runtime-layout-apply.ts b/src/main/runtime/runtime-layout-apply.ts index cf418a8cd44..54f58502e3f 100644 --- a/src/main/runtime/runtime-layout-apply.ts +++ b/src/main/runtime/runtime-layout-apply.ts @@ -3,6 +3,7 @@ import type { LayoutApplyReply, LayoutCommand } from '../../shared/workspace-layout/workspace-layout-command-types' +import type { TerminalLeafMoveRequest } from '../../shared/terminal-leaf-move' import { markAgentLaunchesClosedByUser } from '../agent-launch/agent-launch-pane-attachment' import type { RuntimeStore } from './runtime-store-contract' @@ -13,12 +14,14 @@ export type WindowLayoutCommand = LayoutCommand & /** * `layout.apply`: the window's command on the partitions holding its tab, routed partition first; * a group tree command goes to the workspace's routed partition. A pane close is the user's: main - * marks it so, and the window cannot. + * marks it so, and the window cannot. A pane moved to a new tab follows the desktop's move rules. */ export async function applyWindowLayoutCommand( store: RuntimeStore, hostIdsFor: (worktreeId: string, tabId: string | null) => ExecutionHostId[], - command: WindowLayoutCommand + command: WindowLayoutCommand, + /** Agent status and orchestration records follow a pane moved into a new tab. */ + onPaneMoved: (move: TerminalLeafMoveRequest) => void = () => {} ): Promise { const routedTabId = 'tabId' in command ? command.tabId : command.type === 'closeTabs' ? command.tabIds[0] : null @@ -36,12 +39,7 @@ export async function applyWindowLayoutCommand( // The window already confirmed, removed the tab and stopped its terminals: main forces the // close, records it, and stops nothing (a terminal another tab shares must live on). if (command.recordClose === 'user') { - // Launches go by the terminal's own id, which a legacy tab-bar entry's can differ from. - const tabId = - store - .getWorkspaceSession?.(hostIds[0]) - ?.unifiedTabs?.[command.workspace]?.find((entry) => entry.id === command.tabIds[0]) - ?.entityId ?? command.tabIds[0] + const tabId = terminalIdOf(store, hostIds[0], command.workspace, command.tabIds[0]) markAgentLaunchesClosedByUser(command.workspace, { kind: 'tab', tabId }) } const reply = await store.applyLayoutCommand({ ...command, force: true }, holders) @@ -50,6 +48,30 @@ export async function applyWindowLayoutCommand( ? { ok: false, code: 'workspace_not_found' } : reply } + if (command.type === 'movePaneToNewTab') { + // Read before the move, which re-homes the pane. + const sourceTabId = terminalIdOf(store, hostIds[0], command.workspace, command.tabId) + const reply = await store.applyLayoutCommand( + { ...command, windowDetach: { ptyId: command.windowDetach?.ptyId ?? null } }, + holders + ) + if (reply.ok && reply.result.tabId) { + const move = { + worktreeId: command.workspace, + sourceTabId, + targetTabId: reply.result.tabId, + leafId: command.leafId, + ptyId: reply.result.ptyId ?? null + } + // Why: the move is committed; a throw here would send the window to today's move as well. + try { + onPaneMoved(move) + } catch (error) { + console.warn('[layout] moved pane kept some of its old keys:', error) + } + } + return reply + } if (command.type !== 'closePane') { return store.applyLayoutCommand(command, [hostIds[0]]) } @@ -58,3 +80,17 @@ export async function applyWindowLayoutCommand( markAgentLaunchesClosedByUser(workspace, { kind: 'pane', tabId, leafId }) return store.applyLayoutCommand({ ...command, userPaneClose: true }, holders) } + +/** A tab-bar id's terminal id, which a legacy entry's can differ from. */ +function terminalIdOf( + store: RuntimeStore, + hostId: ExecutionHostId, + workspace: string, + tabId: string +): string { + return ( + store + .getWorkspaceSession?.(hostId) + ?.unifiedTabs?.[workspace]?.find((entry) => entry.id === tabId)?.entityId ?? tabId + ) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-detach-layout-owner.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-detach-layout-owner.test.ts new file mode 100644 index 00000000000..61d71bd3973 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-detach-layout-owner.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command' +import type { + TerminalLeafMoveRequest, + TerminalLeafMoveResult +} from '../../../../shared/terminal-leaf-move' +import { moveLeafThroughLayoutOwner } from './terminal-pane-detach-layout-owner' +import { detachTerminalPaneToTab } from './terminal-pane-tab-detach' +import { + createStore, + LEAF_1, + LEAF_2, + SOURCE_TAB_ID, + TARGET_GROUP_ID, + WORKTREE_ID +} from './terminal-pane-tab-detach-fixture' + +const mocks = vi.hoisted(() => ({ + send: vi.fn(), + toastError: vi.fn() +})) +vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) +vi.mock('@/runtime/layout-owner-command', () => ({ sendLayoutCommandToOwner: mocks.send })) +vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) + +const legacyMove = vi.fn<(request: TerminalLeafMoveRequest) => Promise>() +const request: TerminalLeafMoveRequest = { + worktreeId: WORKTREE_ID, + sourceTabId: SOURCE_TAB_ID, + targetTabId: 'tab-window', + leafId: LEAF_2, + ptyId: 'pty-right' +} +const lastHandlers = () => mocks.send.mock.calls.at(-1)![2] + +beforeEach(() => { + mocks.send.mockReset() + mocks.send.mockReturnValue(true) + mocks.toastError.mockClear() + legacyMove.mockReset() + legacyMove.mockResolvedValue({ status: 'not_held' }) + vi.stubGlobal('window', { + api: { pty: { moveLeafToNewTab: legacyMove }, session: { closeTerminalSurface: vi.fn() } } + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +describe('moveLeafThroughLayoutOwner', () => { + it('sends the drag-out with the window’s terminal and answers with main’s tab', async () => { + const moving = moveLeafThroughLayoutOwner(request, { groupId: TARGET_GROUP_ID, index: 1 }) + + expect(mocks.send).toHaveBeenCalledWith( + WORKTREE_ID, + { + type: 'movePaneToNewTab', + tabId: SOURCE_TAB_ID, + leafId: LEAF_2, + groupId: TARGET_GROUP_ID, + index: 1, + windowDetach: { ptyId: 'pty-right' } + }, + expect.anything() + ) + lastHandlers().apply({ tabId: 'tab-main', ptyId: 'pty-right' }) + await expect(moving).resolves.toEqual({ + status: 'moved', + ptyId: 'pty-right', + tabId: 'tab-main' + }) + expect(legacyMove).not.toHaveBeenCalled() + }) + + it('moves as today when main lacks the pane, or a paired server owns the workspace', async () => { + const lacking = moveLeafThroughLayoutOwner(request, { + groupId: TARGET_GROUP_ID, + index: undefined + }) + lastHandlers().asToday() + await expect(lacking).resolves.toEqual({ status: 'not_held' }) + + mocks.send.mockReturnValue(false) + await moveLeafThroughLayoutOwner(request, { groupId: TARGET_GROUP_ID, index: undefined }) + expect(legacyMove).toHaveBeenCalledTimes(2) + expect(legacyMove).toHaveBeenCalledWith(request) + }) + + it('reports main’s refusal as the move’s', async () => { + const moving = moveLeafThroughLayoutOwner(request, { + groupId: TARGET_GROUP_ID, + index: undefined + }) + lastHandlers().refused!('pty_mismatch') + await expect(moving).resolves.toEqual({ status: 'refused', reason: 'pty_mismatch' }) + }) +}) + +describe('detachTerminalPaneToTab in a layout-owner build', () => { + function detach(store: ReturnType) { + return detachTerminalPaneToTab({ + getStore: () => store, + manager: { + getPanes: () => [{ id: 1 }, { id: 2 }], + getLeafId: (paneId: number) => (paneId === 2 ? LEAF_2 : LEAF_1), + detachPaneForExternalMove: () => true + }, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 2, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID + }) + } + + it('opens the new tab under main’s id', async () => { + const store = createStore() + mocks.send.mockImplementation((_worktreeId, _command, handlers) => { + handlers.apply({ tabId: 'tab-main', ptyId: 'pty-right' }) + return true + }) + + await expect(detach(store)).resolves.toMatchObject({ leafId: LEAF_2, ptyId: 'pty-right' }) + + expect(store.createTab).toHaveBeenCalledWith( + WORKTREE_ID, + TARGET_GROUP_ID, + 'powershell.exe', + expect.objectContaining({ id: 'tab-main', initialLeafId: LEAF_2 }) + ) + expect(legacyMove).not.toHaveBeenCalled() + }) + + it('keeps the pane and says so when main refuses', async () => { + const store = createStore() + mocks.send.mockImplementation((_worktreeId, _command, handlers) => { + handlers.refused?.('pane_in_other_tab') + return true + }) + + await expect(detach(store)).resolves.toBeNull() + + expect(store.createTab).not.toHaveBeenCalled() + expect(mocks.toastError).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-detach-layout-owner.ts b/src/renderer/src/components/terminal-pane/terminal-pane-detach-layout-owner.ts new file mode 100644 index 00000000000..d9a17b5fa29 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-detach-layout-owner.ts @@ -0,0 +1,53 @@ +import { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command' +import type { + TerminalLeafMoveRequest, + TerminalLeafMoveResult +} from '../../../../shared/terminal-leaf-move' + +/** + * A layout-owner build's drag-out: main moves the pane under the desktop's move rules and names the + * new tab. Today's move runs instead when main cannot act on it (a paired server's workspace, or a + * tab or pane main's copy lacks, such as a legacy tab whose tab-bar id differs from its terminal's). + */ +export function moveLeafThroughLayoutOwner( + request: TerminalLeafMoveRequest, + placement: { groupId: string; index: number | undefined } +): Promise { + const asToday = (): Promise => window.api.pty.moveLeafToNewTab(request) + return new Promise((resolve, reject) => { + const fallBack = (): void => void asToday().then(resolve, reject) + const sent = sendLayoutCommandToOwner( + request.worktreeId, + { + type: 'movePaneToNewTab', + tabId: request.sourceTabId, + leafId: request.leafId, + groupId: placement.groupId, + ...(placement.index !== undefined ? { index: placement.index } : {}), + windowDetach: { ptyId: request.ptyId } + }, + { + apply: (result) => + resolve( + result.tabId + ? { status: 'moved', ptyId: result.ptyId ?? null, tabId: result.tabId } + : { status: 'refused', reason: 'invalid_request' } + ), + asToday: fallBack, + refused: (code) => + resolve({ + status: 'refused', + reason: + code === 'pty_mismatch' + ? 'pty_mismatch' + : code === 'pane_in_other_tab' + ? 'leaf_in_other_tab' + : 'invalid_request' + }) + } + ) + if (!sent) { + fallBack() + } + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts index b71d435af53..9a63e1caefe 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts @@ -7,6 +7,8 @@ import type { TerminalLeafMoveRequest } from '../../../../shared/terminal-leaf-m import { commitTerminalSurfaceClose } from '@/store/terminals/terminal-surface-close-intent' import type { PaneCwdEntry } from './resolve-split-cwd' import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' +import { moveLeafThroughLayoutOwner } from './terminal-pane-detach-layout-owner' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' export { isTerminalTabStripDropTarget, resolveTerminalTabStripDropTarget @@ -139,12 +141,24 @@ async function moveLeafToNewTab( leafId, ptyId: args.livePtyId ?? persistedPtyId ?? null } - const answer = await window.api.pty.moveLeafToNewTab(request).catch((error: unknown) => { + // Switch point: main moves the pane and names the new tab. + const answer = await ( + ORCA_LAYOUT_OWNER_IS_MAIN + ? moveLeafThroughLayoutOwner(request, { + groupId: args.targetGroupId, + index: args.targetIndex + }) + : window.api.pty.moveLeafToNewTab(request) + ).catch((error: unknown) => { console.warn('[terminal-pane-detach] main did not answer the move', error) return null }) if (answer?.status === 'moved') { - return applyMove(args, request, true, answer.ptyId) + const moved = + ORCA_LAYOUT_OWNER_IS_MAIN && answer.tabId + ? { ...request, targetTabId: answer.tabId } + : request + return applyMove(args, moved, true, answer.ptyId) } if (answer?.status === 'not_held') { return applyMove(args, request, false, request.ptyId) diff --git a/src/renderer/src/runtime/layout-apply-send.ts b/src/renderer/src/runtime/layout-apply-send.ts index cdb20f7e8f6..4af4312a034 100644 --- a/src/renderer/src/runtime/layout-apply-send.ts +++ b/src/renderer/src/runtime/layout-apply-send.ts @@ -25,6 +25,14 @@ export type WindowLayoutCommand = | { type: 'closeTabs'; tabIds: [string]; recordClose: 'user' | 'cleanup' } | { type: 'renameTab'; tabId: string; kind: 'custom'; title: string | null } | { type: 'setTabProps'; tabId: string; color?: string | null; isPinned?: boolean } + | { + type: 'movePaneToNewTab' + tabId: string + leafId: string + groupId: string + index?: number + windowDetach: { ptyId: string | null } + } export type LayoutApplyHandlers = { apply: (result: LayoutCommandResult) => void diff --git a/src/shared/rpc-contract/workspace-layout-params.ts b/src/shared/rpc-contract/workspace-layout-params.ts index 604748929de..f9c17706f18 100644 --- a/src/shared/rpc-contract/workspace-layout-params.ts +++ b/src/shared/rpc-contract/workspace-layout-params.ts @@ -93,6 +93,16 @@ const CloseTabsCommand = z.object({ recordClose: z.enum(['user', 'cleanup']) }) +/** A window's drag-out of one pane into a new tab; main mints the tab's id. */ +const MovePaneToNewTabCommand = z.object({ + type: z.literal('movePaneToNewTab'), + ...OnPane, + groupId: z.string().min(1), + index: z.number().int().nonnegative().optional(), + /** The terminal the window's pane shows now, when it has one. */ + windowDetach: z.object({ ptyId: z.string().min(1).max(512).nullable() }) +}) + /** * A terminal tab under the ids the window drew it with: its callers and the pane's terminal need * them at once. Main refuses an id another tab holds; the same create again changes nothing. @@ -152,6 +162,7 @@ export const LayoutApplyParams = z.object({ SetGroupRatiosCommand, CloseTabsCommand, RenameTabCommand, - SetTabPropsCommand + SetTabPropsCommand, + MovePaneToNewTabCommand ]) }) diff --git a/src/shared/terminal-leaf-move.ts b/src/shared/terminal-leaf-move.ts index ddf3a823619..a184acc255b 100644 --- a/src/shared/terminal-leaf-move.ts +++ b/src/shared/terminal-leaf-move.ts @@ -12,7 +12,12 @@ export type TerminalLeafMoveRequest = { export type TerminalLeafMoveResult = /** Main moved the leaf and every pane-keyed record into the target tab in one durable write. */ - | { status: 'moved'; ptyId: string | null } + | { + status: 'moved' + ptyId: string | null + /** The new tab's id when main named it (layout-owner builds); else the request's. */ + tabId?: string + } /** Main's session never held the leaf, so there is nothing to move there. */ | { status: 'not_held' } | { diff --git a/src/shared/workspace-layout/workspace-layout-command-types.ts b/src/shared/workspace-layout/workspace-layout-command-types.ts index 5c77396682b..9dcb298ff75 100644 --- a/src/shared/workspace-layout/workspace-layout-command-types.ts +++ b/src/shared/workspace-layout/workspace-layout-command-types.ts @@ -16,6 +16,16 @@ import type { LayoutEditorFile, LayoutTerminalCreation } from './workspace-layou type On = { workspace: string } +/** + * The desktop's drag-out rules: the terminal the window's pane shows outranks a stale binding + * here, and a tab's last pane moves too (the window holds siblings this copy lacks). + */ +export type WindowPaneDetach = { + ptyId: string | null + /** The new tab's id, the same in every partition holding the pane; minted when absent. */ + tabId?: string +} + export type LayoutCommand = | (On & { type: 'createTerminalTab' @@ -76,6 +86,8 @@ export type LayoutCommand = leafId: string groupId?: string index?: number + /** A window's drag-out, under the desktop's move rules (WindowPaneDetach). */ + windowDetach?: WindowPaneDetach }) | (On & { type: 'renameTab'; tabId: string } & TabTitle) | (On & { type: 'renamePane'; tabId: string; leafId: string; title: string | null }) @@ -151,6 +163,9 @@ export type LayoutRefusalCode = | 'pane_already_bound' | 'workspace_exists' | 'tab_exists' + /** A window's drag-out: another tab holds the pane, or no copy binds the window's terminal. */ + | 'pane_in_other_tab' + | 'pty_mismatch' /** What the runtime does after applying: stop or start terminals. The reply never waits on them. */ export type LayoutEffects = { stopPtyIds: string[]; startPaneKeys: string[] } @@ -161,6 +176,8 @@ export type LayoutCommandResult = { paneKey?: string groupId?: string pageId?: string + /** A window's drag-out: the terminal the moved pane shows, null when unbound. */ + ptyId?: string | null tabClosed?: boolean alreadyClosed?: boolean closed?: string[] diff --git a/src/shared/workspace-layout/workspace-layout-commands.test.ts b/src/shared/workspace-layout/workspace-layout-commands.test.ts index 71b4cb4ba94..484e28ae5f2 100644 --- a/src/shared/workspace-layout/workspace-layout-commands.test.ts +++ b/src/shared/workspace-layout/workspace-layout-commands.test.ts @@ -384,6 +384,80 @@ describe('layout command effects', () => { ) }) + it('a window drag-out names the tab, outranks a stale binding and moves a last pane', () => { + const { model, tabA, tabB, leafA2, leafB, ctx } = setup() + const workspace = model.workspaces[WS]! + const withData: WorkspaceLayoutModel = { + ...model, + workspaces: { + [WS]: { + ...workspace, + leaves: { ...workspace.leaves, [leafA2]: { ptyId: 'pty-old', incarnationId: 'inc' } } + } + } + } + const stale = build( + ctx, + [ + { + type: 'movePaneToNewTab', + workspace: WS, + tabId: tabA, + leafId: leafA2, + windowDetach: { ptyId: 'pty-live', tabId: 'tab-main' } + } + ], + withData + ) + expect(stale.results[0]).toEqual({ tabId: 'tab-main', ptyId: 'pty-live' }) + expect(stale.model.workspaces[WS]!.leaves?.[leafA2]).toEqual({ ptyId: 'pty-live' }) + // A copy that never bound the pane keeps its incarnation, as the desktop's move does. + const unbound = applyLayoutCommand( + { + ...model, + workspaces: { + [WS]: { + ...workspace, + leaves: { ...workspace.leaves, [leafA2]: { incarnationId: 'inc' } } + } + } + }, + { + type: 'movePaneToNewTab', + workspace: WS, + tabId: tabA, + leafId: leafA2, + windowDetach: { ptyId: 'pty-live', tabId: 'tab-main' } + }, + ctx + ) + expect(unbound.ok && unbound.model.workspaces[WS]!.leaves?.[leafA2]).toEqual({ + ptyId: 'pty-live', + incarnationId: 'inc' + }) + + // Main's copy lacks the window's other panes, so the tab stays for them (no rule check: to + // main's copy alone it reads as the tab re-ided). + const last = applyLayoutCommand( + model, + { + type: 'movePaneToNewTab', + workspace: WS, + tabId: tabB, + leafId: leafB, + windowDetach: { ptyId: null } + }, + ctx + ) + if (!last.ok) { + throw new Error(last.code) + } + const newTabId = last.result.tabId! + expect(terminalTab(last.model, newTabId).panes.root).toEqual({ type: 'leaf', leafId: leafB }) + expect(terminalTab(last.model, tabB).panes.root).toBeNull() + expect(last.result.ptyId).toBeNull() + }) + it('equalizes same-axis panes into equal shares', () => { const { model, tabA, leafA2, ctx } = setup() const three = build( diff --git a/src/shared/workspace-layout/workspace-layout-pane-commands.ts b/src/shared/workspace-layout/workspace-layout-pane-commands.ts index 434bfd29b06..5b79a5cc8f1 100644 --- a/src/shared/workspace-layout/workspace-layout-pane-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-pane-commands.ts @@ -19,10 +19,11 @@ import { updateTab, type Applied } from './workspace-layout-command-steps' -import type { CommandOf, LayoutContext } from './workspace-layout-command-types' +import type { CommandOf, LayoutContext, WindowPaneDetach } from './workspace-layout-command-types' import { paneKeyOf, type LayoutTerminalTab, + type WorkspaceLayout, type WorkspaceLayoutModel } from './workspace-layout-model' import { retireTerminalPane, withWorkspace } from './workspace-layout-removal' @@ -168,12 +169,13 @@ export function movePaneToNewTab( if (!layoutContainsLeafId(tab.panes.root, command.leafId)) { return refuse('pane_not_found') } - if (leafIdsOf(tab).length < 2) { + const detach = command.windowDetach + if (!detach && leafIdsOf(tab).length < 2) { return refuse('last_pane') } const { leafId } = command - const workspace = model.workspaces[command.workspace]! - const id = context.mintId() + const workspace = bindDetachedLeaf(model.workspaces[command.workspace]!, leafId, detach) + const id = detach?.tabId ?? context.mintId() const terminals = workspace.tabs.flatMap((entry) => (entry.kind === 'terminal' ? [entry] : [])) const ordinal = getNextTerminalOrdinal( terminals.map((entry) => ({ defaultTitle: entry.terminal.defaultTitle, title: '' })) @@ -217,5 +219,25 @@ export function movePaneToNewTab( }, context ) - return applied(withWorkspace(model, command.workspace, placed), { tabId: id }) + return applied(withWorkspace(model, command.workspace, placed), { + tabId: id, + ...(detach ? { ptyId: placed.leaves?.[leafId]?.ptyId ?? null } : {}) + }) +} + +/** The window's terminal outranks a stale binding here; a stale copy's incarnation is dropped. */ +function bindDetachedLeaf( + workspace: WorkspaceLayout, + leafId: string, + detach: WindowPaneDetach | undefined +): WorkspaceLayout { + const leaf = workspace.leaves?.[leafId] + if (!detach?.ptyId || leaf?.ptyId === detach.ptyId) { + return workspace + } + if (!leaf?.ptyId) { + return setLeaf(workspace, leafId, { ...leaf, ptyId: detach.ptyId }) + } + const { incarnationId: _stale, ...rest } = leaf + return setLeaf(workspace, leafId, { ...rest, ptyId: detach.ptyId }) } 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 54f0b2196f8..8de3ed4b9fe 100644 --- a/src/shared/workspace-layout/workspace-layout-record-patch.test.ts +++ b/src/shared/workspace-layout/workspace-layout-record-patch.test.ts @@ -93,7 +93,17 @@ function commandsFor(model: WorkspaceLayoutModel): LayoutCommand[] { title: 'Pane' }) } + if (first) { + commands.push({ + type: 'movePaneToNewTab', + workspace, + tabId: tab.id, + leafId: first, + windowDetach: { ptyId: 'pty-detached', tabId: 'tab-detached' } + }) + } if (second) { + commands.push({ type: 'movePaneToNewTab', workspace, tabId: tab.id, leafId: second }) commands.push({ type: 'closePane', workspace, tabId: tab.id, leafId: second }) commands.push({ type: 'closePane',