diff --git a/src/main/ipc/pty-dead-owner-respawn.test.ts b/src/main/ipc/pty-dead-owner-respawn.test.ts index 7a72c0d53b49..b12ef3ea808f 100644 --- a/src/main/ipc/pty-dead-owner-respawn.test.ts +++ b/src/main/ipc/pty-dead-owner-respawn.test.ts @@ -3,6 +3,7 @@ import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixtur import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { SessionNotFoundError } from '../daemon/daemon-errors' import { makePaneKey } from '../../shared/stable-pane-id' +import { releaseTabRowPty } from '../persistence/loading-store/replaced-pane-binding' import { registerPtyHandlers, setLocalPtyProvider } from './pty' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) @@ -109,6 +110,7 @@ describe('registerPtyHandlers', () => { session = next }), flushOrThrow: vi.fn(), + retirePtyBinding: vi.fn(async () => true), persistPtyBinding: vi.fn(), getFolderWorkspace: vi.fn(() => undefined), getFolderWorkspaces: vi.fn(() => []), @@ -171,8 +173,13 @@ describe('registerPtyHandlers', () => { 'inc-proven-absent-owner', { hostExitConfirmed: true } ) - expect(store.setWorkspaceSession).toHaveBeenCalledOnce() - expect(store.flushOrThrow).toHaveBeenCalledOnce() + // The pane and its binding stay for the fresh spawn's bind to swap; only the row lets go. + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + expect(store.retirePtyBinding).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId, tabId, leafId, ptyId: 'pty-proven-absent-owner' }), + undefined, + releaseTabRowPty + ) }) it('does not poll after the routed provider confirms absence', async () => { const worktreeId = 'repo-1::/tmp/probe-blip-owner' @@ -233,6 +240,7 @@ describe('registerPtyHandlers', () => { session = next }), flushOrThrow: vi.fn(), + retirePtyBinding: vi.fn(async () => true), persistPtyBinding: vi.fn(), getFolderWorkspace: vi.fn(() => undefined), getFolderWorkspaces: vi.fn(() => []), @@ -357,6 +365,7 @@ describe('registerPtyHandlers', () => { session = next }), flushOrThrow: vi.fn(), + retirePtyBinding: vi.fn(async () => true), persistPtyBinding: vi.fn(), getFolderWorkspace: vi.fn(() => undefined), getFolderWorkspaces: vi.fn(() => []), diff --git a/src/main/ipc/pty-pane-reservation-settlement.test.ts b/src/main/ipc/pty-pane-reservation-settlement.test.ts index 30ef457a81d3..ee4e26bddcd0 100644 --- a/src/main/ipc/pty-pane-reservation-settlement.test.ts +++ b/src/main/ipc/pty-pane-reservation-settlement.test.ts @@ -123,6 +123,7 @@ describe('registerPtyHandlers', () => { session = next }), flushOrThrow: vi.fn(), + retirePtyBinding: vi.fn(async () => true), persistPtyBinding: vi.fn(), upsertSshRemotePtyLease: vi.fn(), supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), @@ -184,16 +185,16 @@ describe('registerPtyHandlers', () => { expect(remoteSpawn.mock.calls[1]?.[0]).toMatchObject({ command: 'codex resume exact-dead-ssh-provider-session' }) - expect(store.setWorkspaceSession).toHaveBeenCalledWith(expect.anything(), hostId) - expect(store.persistPtyBinding).toHaveBeenCalledWith( - expect.objectContaining({ - worktreeId, - tabId, - leafId, - ptyId: freshPtyId - }), - hostId - ) + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + // The fresh spawn replaces the absent owner, so its bind swaps the binding naming it. + const [input, persistHostId] = store.persistPtyBinding.mock.calls[0] ?? [] + expect(persistHostId).toBe(hostId) + expect(typeof input === 'function' ? input() : input).toMatchObject({ + worktreeId, + tabId, + leafId, + ptyId: freshPtyId + }) } finally { unregisterSshPtyProvider(connectionId) } diff --git a/src/main/ipc/pty-persisted-incarnation-repair.test.ts b/src/main/ipc/pty-persisted-incarnation-repair.test.ts index c057d6ec5d21..9cf6e42cc5cf 100644 --- a/src/main/ipc/pty-persisted-incarnation-repair.test.ts +++ b/src/main/ipc/pty-persisted-incarnation-repair.test.ts @@ -279,6 +279,7 @@ describe('registerPtyHandlers', () => { session = next }), flushOrThrow: vi.fn(), + retirePtyBinding: vi.fn(async () => true), persistPtyBinding: vi.fn(), getFolderWorkspace: vi.fn(() => ({ id: 'dead-persisted-owner', @@ -350,7 +351,7 @@ describe('registerPtyHandlers', () => { sessionId: 'pty-dead-persisted-owner', command: undefined }) - expect(store.setWorkspaceSession).toHaveBeenCalledOnce() + expect(store.setWorkspaceSession).not.toHaveBeenCalled() expect(runtime.onPtyExit).toHaveBeenCalledWith( 'pty-dead-persisted-owner', -1, @@ -374,8 +375,8 @@ describe('registerPtyHandlers', () => { expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({ command: 'codex resume exact-dead-provider-session' }) - expect(store.setWorkspaceSession).toHaveBeenCalledOnce() - expect(store.flushOrThrow).toHaveBeenCalledOnce() + // The pane and its binding stay for the fresh spawn's bind to swap. + expect(store.setWorkspaceSession).not.toHaveBeenCalled() expect(runtime.onPtyExit).toHaveBeenCalledWith( 'pty-dead-persisted-owner', -1, @@ -441,6 +442,7 @@ describe('registerPtyHandlers', () => { session = next }), flushOrThrow: vi.fn(), + retirePtyBinding: vi.fn(async () => true), persistPtyBinding: vi.fn(), getFolderWorkspace: vi.fn(() => undefined), getFolderWorkspaces: vi.fn(() => []), diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index 4b8d71c04417..894eb73df2c3 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -67,6 +67,8 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis : null ctx.spawnTiming.mark('stable_adoption') if (ctx.earlyStablePaneOwner && !ctx.preAdoptedStablePane) { + // The owner's process is absent; this spawn replaces it, so it is not reattached again. + ctx.replacedPaneOwner ??= { ptyId: ctx.earlyStablePaneOwner.ptyId, pane: null } const pathUsable = ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) if (pathUsable) { await pathUsable diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts index 83ebdd50364d..4cf081d6b6b3 100644 --- a/src/main/ipc/pty/pane/stable-owner.ts +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -17,6 +17,7 @@ import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' import { isHostReportedPtyAbsenceError, isObservedPtyExitEvidence } from '../provider/liveness' import { clearProviderPtyState } from '../provider/state-cleanup' import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' +import { releaseTabRowPty } from '../../../persistence/loading-store/replaced-pane-binding' export type StablePaneOwner = { handle?: string @@ -225,7 +226,7 @@ export async function persistAdmittedStablePaneBinding(args: { export async function attachStablePaneOwner( args: StablePaneSpawnContext & { owner: StablePaneOwner } ): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> { - const { owner, provider, runtime, spawnOptions } = args + const { owner, provider, runtime, spawnOptions, store, worktreeId, connectionId } = args let result: PtySpawnResult try { result = await provider.spawn({ @@ -265,6 +266,10 @@ export async function attachStablePaneOwner( ) { throw new Error('terminal_pane_owner_changed') } + // Why 'replaced': the fresh spawn that follows takes the pane, so main keeps the pane and its + // binding until that spawn's bind swaps it, as for a restart. + const stops = runtime?.intentionalPtyStops + const settleReplacement = stops?.mark(owner.ptyId, 'replaced', owner.incarnationId ?? null) // `pty.attach` answers absent both for a pid the relay probed and found gone and for an id its // session map never had — every id minted before a relay restart, checked against nothing. Only // the marked half observed the process, so only it may certify a death; the rest publishes the @@ -276,16 +281,13 @@ export async function attachStablePaneOwner( owner.incarnationId, isObservedPtyExitEvidence(error) ? { hostExitConfirmed: true } : {} ) + settleReplacement?.(true) clearProviderPtyState(owner.ptyId) ptyOwnership.delete(owner.ptyId) - if ( - args.worktreeId && - !(await retirePersistedStablePaneOwner(args.store, owner, args.worktreeId, args.connectionId)) - ) { - throw new Error('terminal_pane_owner_changed') - } - if (args.resolveOwner?.()) { - throw new Error('terminal_pane_owner_changed') + if (worktreeId) { + // The first pane to bind takes the row, as after the window's hydration. + const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined + await store?.retirePtyBinding({ ...owner, worktreeId }, hostId, releaseTabRowPty) } return null } diff --git a/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts b/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts index 390bcdd451a2..7f89e95b5574 100644 --- a/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts +++ b/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts @@ -11,7 +11,6 @@ import { describe, expect, it, vi } from 'vitest' import { withDurableRuntimeStore } from '../../../runtime/runtime-durable-store-fixture' import { getDefaultWorkspaceSession } from '../../../../shared/constants' -import { makePaneKey } from '../../../../shared/stable-pane-id' import { SSH_EXIT_UNCONFIRMED_REASON } from '../../../../shared/pty-liveness-verdict' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' import { SessionNotFoundError } from '../../../daemon/daemon-errors' @@ -23,7 +22,7 @@ import { } from '../../../providers/ssh-pty-errors' import type { IPtyProvider } from '../../../providers/types' import { OrcaRuntimeService } from '../../../runtime/orca-runtime' -import { resolvePersistedStablePaneOwner, spawnForStablePane } from './stable-owner' +import { spawnForStablePane } from './stable-owner' const CONNECTION = 'conn-1' const WORKTREE = 'repo-1::/tmp/pane-absence' @@ -62,6 +61,7 @@ function paneStore(): { store: Store; read: () => WorkspaceSessionState } { session = next }, flushOrThrow: () => {}, + retirePtyBinding: async () => true, getRepos: () => [ { id: 'repo-1', @@ -137,16 +137,12 @@ describe('a stable pane whose reattach was refused', () => { }) it('still certifies the death the relay proved with a pid probe', async () => { - const { runtime, store, spawn } = await adoptAfterAttachRefusal( + const { runtime, spawn } = await adoptAfterAttachRefusal( new SshPtyProvenExitedOnRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty2:epoch-a:1`) ) expect(spawn).toHaveBeenCalledTimes(2) expect(runtime.getPtyLivenessVerdict(PTY_ID)).toEqual({ status: 'exited' }) - // If nothing ever retired, a proven-dead pane would reattach to a corpse on every adoption. - expect( - resolvePersistedStablePaneOwner(store, makePaneKey(TAB, LEAF), WORKTREE, CONNECTION) - ).toBeNull() }) it('certifies an absence reported by the process registry that owns the PTY', async () => { diff --git a/src/main/ipc/pty/pane/stable-pane-absent-respawn-keeps-pane.test.ts b/src/main/ipc/pty/pane/stable-pane-absent-respawn-keeps-pane.test.ts new file mode 100644 index 000000000000..3526215f6d05 --- /dev/null +++ b/src/main/ipc/pty/pane/stable-pane-absent-respawn-keeps-pane.test.ts @@ -0,0 +1,141 @@ +// Waking a slept split after a relaunch reattaches each pane to a process the sleep stopped. The +// host answers absent and the pane spawns fresh, so main's copy must keep the pane and its binding +// until the fresh spawn's bind swaps it: dropping the pane lets that spawn re-mint the tab and +// reorder the split, and a pane shown unbound meanwhile reads as gone to the agent resume. +import { describe, expect, it, vi } from 'vitest' +import { withDurableRuntimeStore } from '../../../runtime/runtime-durable-store-fixture' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { SessionNotFoundError } from '../../../daemon/daemon-errors' +import type { Store } from '../../../persistence' +import { clearReplacedPaneBinding } from '../../../persistence/loading-store/replaced-pane-binding' +import type { IPtyProvider } from '../../../providers/types' +import { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { spawnForStablePane } from './stable-owner' + +const WORKTREE = 'repo-1::/tmp/pane-respawn' +const TAB = 'tab-1' +const SHELL_LEAF = '1b3f2c4d-5e6a-4b7c-8d9e-0f1a2b3c4d5e' +const AGENT_LEAF = '2c4d3e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f' +const SHELL_PTY = `${WORKTREE}@@aaaa0001` +const AGENT_PTY = `${WORKTREE}@@aaaa0002` +const SPLIT: TerminalPaneLayoutNode = { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: SHELL_LEAF }, + second: { type: 'leaf', leafId: AGENT_LEAF } +} + +const ROW: TerminalTab = { + id: TAB, + worktreeId: WORKTREE, + ptyId: SHELL_PTY, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 +} + +function sleptSplit(): { store: Store; read: () => WorkspaceSessionState } { + let session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [WORKTREE]: [ROW] }, + terminalLayoutsByTabId: { + [TAB]: { + root: SPLIT, + activeLeafId: AGENT_LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [SHELL_LEAF]: SHELL_PTY, [AGENT_LEAF]: AGENT_PTY } + } + } + } + return { + read: () => session, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the persistence and catalog reads stable-pane adoption and exit bookkeeping use. + store: withDurableRuntimeStore({ + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + }, + // The store's host-side binding retirement, applying the change the caller names. + retirePtyBinding: async ( + ...[binding, , retire = clearReplacedPaneBinding]: Parameters + ) => { + session = retire(session, { ...binding, parentTabId: binding.tabId }) + return true + }, + flushOrThrow: () => {}, + getRepos: () => [ + { + id: 'repo-1', + path: '/tmp/pane-respawn', + displayName: 'r', + badgeColor: '#000', + addedAt: 0 + } + ], + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => {}, + removeWorktreeMeta: () => {}, + getSettings: () => ({ workspaceDir: '/tmp/workspaces' }), + getProjects: () => [] + }) as unknown as Store + } +} + +/** The waking window names each pane's slept PTY, as its graph does before the reattach. */ +function wakingRuntime(store: Store): OrcaRuntimeService { + const runtime = new OrcaRuntimeService(store as never) + runtime.setPtyController({ + write: () => true, + kill: () => true, + hasPty: () => null, + listProcesses: async () => [], + getForegroundProcess: async () => null + } as never) + runtime.attachWindow(1) + runtime.registerPty(SHELL_PTY, WORKTREE, null, { tabId: TAB, leafId: SHELL_LEAF }) + runtime.registerPty(AGENT_PTY, WORKTREE, null, { tabId: TAB, leafId: AGENT_LEAF }) + return runtime +} + +async function respawnOverAbsentProcess( + runtime: OrcaRuntimeService, + store: Store, + leafId: string, + ptyId: string +): Promise { + const spawn = vi + .fn() + .mockRejectedValueOnce(new SessionNotFoundError(ptyId)) + .mockResolvedValueOnce({ id: `${ptyId}-fresh`, isReattach: false }) + const result = await spawnForStablePane({ + runtime, + store, + provider: { spawn } as unknown as IPtyProvider, + spawnOptions: { cols: 80, rows: 24 }, + owner: { tabId: TAB, leafId, ptyId, hasPersistedBinding: true }, + worktreeId: WORKTREE, + resolveOwner: () => null + }) + expect(spawn).toHaveBeenCalledTimes(2) + expect(result.owner).toBeNull() +} + +describe('a stable pane respawned over an absent process', () => { + it('keeps the panes and their bindings, releasing only the row of the gone process', async () => { + const { store, read } = sleptSplit() + const before = structuredClone(read()) + const runtime = wakingRuntime(store) + + await respawnOverAbsentProcess(runtime, store, SHELL_LEAF, SHELL_PTY) + await respawnOverAbsentProcess(runtime, store, AGENT_LEAF, AGENT_PTY) + + expect(read().terminalLayoutsByTabId).toEqual(before.terminalLayoutsByTabId) + // The row named the shell's gone process; the first pane to bind takes it. + expect(read().tabsByWorktree[WORKTREE]).toEqual([{ ...ROW, ptyId: null }]) + }) +}) diff --git a/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts b/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts index ce3c578cfa03..9ec5988ab763 100644 --- a/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts +++ b/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts @@ -7,6 +7,7 @@ import { SshPtyAbsentFromRelayError } from '../../../providers/ssh-pty-errors' import type { Store } from '../../../persistence' +import { clearReplacedPaneBinding } from '../../../persistence/loading-store/replaced-pane-binding' import type { IPtyProvider } from '../../../providers/types' import { spawnForStablePane, type StablePaneOwner } from './stable-owner' @@ -77,6 +78,13 @@ function sessionStore(leaves: string[]): { store: Store; read: () => WorkspaceSe setWorkspaceSession: (next: WorkspaceSessionState) => { session = next }, + // The store's host-side binding retirement, applying the change the caller names. + retirePtyBinding: async ( + ...[binding, , retire = clearReplacedPaneBinding]: Parameters + ) => { + session = retire(session, { ...binding, parentTabId: binding.tabId }) + return true + }, flushOrThrow: () => {} }) as unknown as Store } @@ -152,47 +160,30 @@ describe('stable pane adoption after the relay reports the PTY absent', () => { expect(spawn).toHaveBeenCalledTimes(1) }) - // Why a real store: without one the `args.worktreeId` guard short-circuits and the retirement — - // which can delete the parent tab and its layout — never runs. "Reconnect lost every tab" is the - // regression this subsystem was reverted for twice, so the composition needs its own coverage. + // Why a real store: "Reconnect lost every tab" is the regression this subsystem was reverted for + // twice. The fresh spawn takes the pane, so the absence leaves the tab, its layout and the + // bindings for that spawn's bind to swap; only the row lets go of the gone process. describe('with persistence actually reached', () => { - it('retires only the absent leaf and leaves the tab and its sibling bound', async () => { - const { store, read } = sessionStore([LEAF, SIBLING_LEAF]) - const { run, spawn } = spawnAfterAttachRejection( - new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty-1`), - { store, worktreeId: WORKTREE } - ) - - const result = await run() - - expect(spawn).toHaveBeenCalledTimes(2) - expect(result.owner).toBeNull() - const session = read() - expect(session.tabsByWorktree[WORKTREE]?.map((tab) => tab.id)).toEqual(['tab-1']) - const layout = session.terminalLayoutsByTabId['tab-1'] - expect(layout).toBeDefined() - expect(layout?.ptyIdsByLeafId?.[SIBLING_LEAF]).toBe('ssh:conn-1@@pty-2') - expect(layout?.ptyIdsByLeafId?.[LEAF]).toBeUndefined() - }) - - // Pins current behaviour rather than blessing it: retiring the LAST leaf drops the tab from - // persistence, and the fallback returns owner=null so main does not re-persist a binding — tab - // survival then rests entirely on the renderer. If that ever regresses, this is the tripwire. - it('drops the tab when the absent leaf was the only one, leaving re-persistence to the renderer', async () => { - const { store, read } = sessionStore([LEAF]) - const { run, spawn } = spawnAfterAttachRejection( - new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty-1`), - { store, worktreeId: WORKTREE } - ) + it.each([[[LEAF, SIBLING_LEAF]], [[LEAF]]])( + 'keeps the tab, its panes and their bindings (leaves %j)', + async (leaves) => { + const { store, read } = sessionStore(leaves) + const layouts = structuredClone(read().terminalLayoutsByTabId) + const { run, spawn } = spawnAfterAttachRejection( + new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty-1`), + { store, worktreeId: WORKTREE } + ) - const result = await run() + const result = await run() - expect(spawn).toHaveBeenCalledTimes(2) - expect(result.owner).toBeNull() - const session = read() - expect(session.tabsByWorktree[WORKTREE]).toEqual([]) - expect(session.terminalLayoutsByTabId['tab-1']).toBeUndefined() - }) + expect(spawn).toHaveBeenCalledTimes(2) + expect(result.owner).toBeNull() + expect(read().terminalLayoutsByTabId).toEqual(layouts) + expect(read().tabsByWorktree[WORKTREE]).toEqual([ + { id: 'tab-1', worktreeId: WORKTREE, ptyId: null } + ]) + } + ) it('leaves persistence untouched when the failure is not positive absence', async () => { const { store, read } = sessionStore([LEAF, SIBLING_LEAF]) diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index 359c55dbed90..46ee6f2b84c2 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -79,10 +79,14 @@ export class PtyBindingPersistenceOperations { this[ptyBindingPersistenceOperationsContext] = { runtime, sessions } } - /** Clears a stopped process's binding, keeping the pane; fenced on the id, which is dead in any incarnation. */ + /** + * Clears a stopped process's binding, keeping the pane; fenced on the id, which is dead in any + * incarnation. `retire` narrows the edit when a replacing spawn's bind will swap the binding. + */ async retirePtyBinding( binding: Pick, - hostId?: string | null + hostId?: string | null, + retire = clearReplacedPaneBinding ): Promise { const { runtime, sessions } = this[ptyBindingPersistenceOperationsContext] const resolved = resolveHostId(hostId) @@ -113,7 +117,7 @@ export class PtyBindingPersistenceOperations { return { value: false, persist: false } } const before = cloneWorkspaceSessionState(session) - const retired = clearReplacedPaneBinding(session, { ...binding, parentTabId: binding.tabId }) + const retired = retire(session, { ...binding, parentTabId: binding.tabId }) // Host retirement must not run renderer snapshot repair, which would put the old binding back. publish(retired) const staged = cloneWorkspaceSessionState(retired) diff --git a/src/main/persistence/loading-store/replaced-pane-binding.ts b/src/main/persistence/loading-store/replaced-pane-binding.ts index 5cefc332677f..81f876abf14f 100644 --- a/src/main/persistence/loading-store/replaced-pane-binding.ts +++ b/src/main/persistence/loading-store/replaced-pane-binding.ts @@ -21,12 +21,25 @@ export function clearReplacedPaneBinding( ...session.terminalLayoutsByTabId, [surface.parentTabId]: { ...layout, ptyIdsByLeafId } }, + tabsByWorktree: releaseTabRowPty(session, surface).tabsByWorktree + } +} + +/** A tab row names a live attachment; a process that is gone leaves it for the next bind to take. */ +export function releaseTabRowPty( + session: WorkspaceSessionState, + surface: Pick +): WorkspaceSessionState { + const tabs = session.tabsByWorktree[surface.worktreeId] ?? [] + if (!tabs.some((tab) => tab.id === surface.parentTabId && tab.ptyId === surface.ptyId)) { + return session + } + return { + ...session, tabsByWorktree: { ...session.tabsByWorktree, - [surface.worktreeId]: (session.tabsByWorktree[surface.worktreeId] ?? []).map((tab) => - tab.id === surface.parentTabId && tab.ptyId === surface.ptyId - ? { ...tab, ptyId: null } - : tab + [surface.worktreeId]: tabs.map((tab) => + tab.id === surface.parentTabId ? { ...tab, ptyId: null } : tab ) } } diff --git a/src/main/runtime/terminal-intentional-stops.ts b/src/main/runtime/terminal-intentional-stops.ts index b89bc709e5f2..4ee52f772221 100644 --- a/src/main/runtime/terminal-intentional-stops.ts +++ b/src/main/runtime/terminal-intentional-stops.ts @@ -3,7 +3,7 @@ import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/vis /** * Why main stopped a PTY on purpose. Both kinds keep the pane's binding through the exit: * - `reversible`: sleep or hibernation; the binding is the wake hint. - * - `replaced`: a restart stops the old process so a new one can take the pane. + * - `replaced`: a new process takes the pane (a restart, or a respawn over an absent one). */ export type TerminalIntentionalStopKind = 'reversible' | 'replaced' diff --git a/src/main/ssh-expired-lease-pane-readoption.test.ts b/src/main/ssh-expired-lease-pane-readoption.test.ts index 11bf5fe2083d..642d9343425e 100644 --- a/src/main/ssh-expired-lease-pane-readoption.test.ts +++ b/src/main/ssh-expired-lease-pane-readoption.test.ts @@ -155,6 +155,12 @@ describe('recovery through createTerminal reattaches before it respawns', () => // A null adoption is exactly what routes createTerminal to a fresh shell, so a pane whose // shell genuinely died still gets a working terminal. await expect(adoptStablePane(undefined, store, ADOPT_ARGS)).resolves.toBeNull() - expect(resolvePersistedStablePaneOwner(store, PANE_KEY, WORKTREE, TARGET)).toBeNull() + // The pane keeps its binding for the fresh shell's bind to swap; only the row lets go. + expect(resolvePersistedStablePaneOwner(store, PANE_KEY, WORKTREE, TARGET)).toMatchObject({ + ptyId: APP_PTY_ID + }) + expect(store.getWorkspaceSession(`ssh:${TARGET}`).tabsByWorktree[WORKTREE]?.[0]?.ptyId).toBe( + null + ) }) }) diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index cbcf78a4abe2..68ec032dde7f 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -8,6 +8,7 @@ import type { TerminalLeafMoveRequest, TerminalLeafMoveResult } from '../../shared/terminal-leaf-move' +import type { TerminalTopologyReply } from '../../shared/terminal-topology-slice' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { PtyListedSession, PtySessionListScope } from '../../shared/pty-listed-session' import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' @@ -83,6 +84,8 @@ export type PtyApi = { agentResumeUnavailable?: true /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean + /** The topology push holding this spawn's binding. */ + publishSeq?: number }> write: (id: string, data: string, inputKind: TerminalInputKind) => void /** `requireWriteSettlement` waits for the provider's acknowledgment on any provider. */ @@ -154,7 +157,9 @@ export type PtyApi = { ids: string[] ) => Promise<{ id: string; authoritative: boolean | null }[]> hasPty: (id: string) => Promise - moveLeafToNewTab: (request: TerminalLeafMoveRequest) => Promise + moveLeafToNewTab: ( + request: TerminalLeafMoveRequest + ) => Promise getMainBufferSnapshot: ( id: string, opts?: { scrollbackRows?: number } diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 3b996d61ea23..9a2e4735a466 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -20,6 +20,7 @@ import type { TerminalViewAttributes } from '../../shared/terminal-view-attribut import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events' import type { PreloadApi } from '../api-types' +import type { TerminalTopologyReply } from '../../shared/terminal-topology-slice' import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement' export const ptySessionControlApi = { @@ -78,6 +79,8 @@ export const ptySessionControlApi = { agentResumeUnavailable?: true /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean + /** The topology push holding this spawn's binding. */ + publishSeq?: number }> => ipcRenderer.invoke('pty:spawn', opts), write: (id: string, data: string, inputKind: TerminalInputKind): void => { ipcRenderer.send('pty:write', { id, data, inputKind }) @@ -167,7 +170,9 @@ export const ptySessionControlApi = { ): Promise<{ id: string; authoritative: boolean | null }[]> => ipcRenderer.invoke('pty:getAuthoritativeBufferSnapshotCapabilities', { ids }), hasPty: (id: string): Promise => ipcRenderer.invoke('pty:hasPty', { id }), - moveLeafToNewTab: (request: TerminalLeafMoveRequest): Promise => + moveLeafToNewTab: ( + request: TerminalLeafMoveRequest + ): Promise => ipcRenderer.invoke('pty:moveLeafToNewTab', request), getMainBufferSnapshot: ( id: string, diff --git a/src/preload/api/session-bridge.ts b/src/preload/api/session-bridge.ts index 537e15179ddd..8516ba992e88 100644 --- a/src/preload/api/session-bridge.ts +++ b/src/preload/api/session-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { PreloadApi } from '../api-types' +import type { TerminalTopologySlice } from '../../shared/terminal-topology-slice' export const sessionApi = { // hostId is optional; main defaults it to 'local' so existing omitting call sites keep the local session partition. @@ -8,6 +9,13 @@ export const sessionApi = { set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), closeTerminalSurface: (args) => ipcRenderer.invoke('session:close-terminal-surface', args), + getTerminalTopologySlices: () => ipcRenderer.invoke('session:get-terminal-topology-slices'), + onTerminalTopologyChanged: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, slice: TerminalTopologySlice): void => + callback(slice) + ipcRenderer.on('session:terminal-topology-changed', listener) + return () => ipcRenderer.removeListener('session:terminal-topology-changed', listener) + }, flush: () => ipcRenderer.invoke('session:flush'), readTerminalScrollback: (args) => ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args), diff --git a/src/preload/api/workspace-session-api.ts b/src/preload/api/workspace-session-api.ts index d621e5dc7db6..97e97d3f27f9 100644 --- a/src/preload/api/workspace-session-api.ts +++ b/src/preload/api/workspace-session-api.ts @@ -5,6 +5,10 @@ import type { } from '../../shared/workspace-session-state-types' import type { ExecutionHostId } from '../../shared/execution-host' import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' +import type { + TerminalTopologyReply, + TerminalTopologySlice +} from '../../shared/terminal-topology-slice' import type { RemoteWorkspaceChangedEvent, RemoteWorkspaceConnectedClient, @@ -25,7 +29,10 @@ export type WorkspaceSessionApi = { worktreeId: string target: TerminalSurfaceCloseTarget reason?: 'user' | 'cleanup' - }) => Promise + }) => Promise + /** Every worktree's current terminal topology; pull after subscribing to the pushes. */ + getTerminalTopologySlices: () => Promise + onTerminalTopologyChanged: (callback: (slice: TerminalTopologySlice) => void) => () => void flush: () => Promise readTerminalScrollback: (args: { ref: string }) => string | null setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 0c0ab0dd5436..0acc4f993d3e 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -9,6 +9,7 @@ import { waitForNativeChatDraftsAtStartup } from './native-chat-draft-startup' import { WORKTREE_REFRESH_CONCURRENCY } from '../store/slices/worktrees' import { sweepRestoredCodexPanesForStaleAccounts } from '../lib/codex-stale-pane-sweep' import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-hydration' +import { followTerminalTopology } from '../lib/terminal-topology-follow' import { collectFolderWorkspaceKeysFromSession, collectWorktreeHydrationRepoIdsFromSession @@ -22,6 +23,7 @@ import { import { recoverFromDegradedStartup } from '../startup/startup-degraded-recovery' import { restoreSshConnectionsForStartup } from '../startup/startup-ssh-connection-restore' import { collectActiveWorkspaceSshTargetIds } from '../startup/active-workspace-ssh-targets' +import { listRuntimeSessionHostIdsForStartup } from '../startup/startup-runtime-session-hosts' import { publishTerminalViewAttributesAtAppStart } from '../components/terminal-pane/terminal-appearance' import { getSystemPrefersDark } from '../lib/terminal-theme' import { @@ -31,25 +33,12 @@ import { import { getRepoExecutionHostId, isRuntimeOwnedSshTargetId, - parseExecutionHostId, - toRuntimeExecutionHostId, - type ExecutionHostId + parseExecutionHostId } from '../../../shared/execution-host' import { mapWithConcurrency } from '../../../shared/map-with-concurrency' import type { OnboardingState } from '../../../shared/onboarding-state-types' import { ensureLocalRuntimeCapabilities } from '../runtime/local-runtime-capabilities' -async function listRuntimeSessionHostIdsForStartup(): Promise { - try { - return (await window.api.runtimeEnvironments.list()).map((environment) => - toRuntimeExecutionHostId(environment.id) - ) - } catch (err) { - console.warn('Failed to list runtime session hosts for startup:', err) - return [] - } -} - /** * Runs the renderer's one-shot boot chain: settings, persisted UI, the local repo catalog, * the workspace session, SSH reconnect, and terminal restoration — then unlocks the session @@ -214,6 +203,14 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta useAppStore.getState().reconcileWorktreeTabModels ) }) + // Why after hydration: hydrating from the earlier session read would roll back a newer slice. + await timeRendererStartupStep('follow-terminal-topology', () => + followTerminalTopology( + window.api.session, + useAppStore.getState().applyTerminalTopologySlice, + abortController.signal + ) + ) await timeRendererStartupStep('prepare-terminal-startup-restoration', () => window.api.app.prepareTerminalStartupRestoration() ) diff --git a/src/renderer/src/lib/session-write-subscriber.ts b/src/renderer/src/lib/session-write-subscriber.ts index 581d9e875c64..c0a9948a9488 100644 --- a/src/renderer/src/lib/session-write-subscriber.ts +++ b/src/renderer/src/lib/session-write-subscriber.ts @@ -135,6 +135,8 @@ export function createSessionWriteSubscriber({ // identity is the only thing the pre-allocation scan below can compare them against. let prevTabsSource: TabsByWorktree | null = null let prevUnifiedTabsSource: UnifiedTabsByWorktree | null = null + // Only a mirror apply moves this map, so a new identity marks the update as main's own state. + let prevTopologySeq: AppState['terminalTopologySeqByWorktree'] | null = null // Why: this set is the only record that a mutation still owes a write — `prev` has already // advanced past it, and change detection is identity-based, so a field dropped from here can // never be re-detected. In-flight fields remain owned by that write until acknowledgment; @@ -243,6 +245,8 @@ export function createSessionWriteSubscriber({ if (!shouldPersistWorkspaceSession(state)) { return } + const mirrored = prev !== null && state.terminalTopologySeqByWorktree !== prevTopologySeq + prevTopologySeq = state.terminalTopologySeqByWorktree // Why: this fires on every store write and almost none of them touch a session field. Scan // identities first so the common case never allocates the 35-field snapshot or the changed // list; only a real identity change pays for them. @@ -281,6 +285,10 @@ export function createSessionWriteSubscriber({ return } prev = next + // Why: main already holds what a mirror apply wrote; saving it back would echo (D8). + if (mirrored) { + return + } for (const field of changedFields) { pendingChangedFields.add(field) } diff --git a/src/renderer/src/lib/setup-split-activation-single-owner.test.ts b/src/renderer/src/lib/setup-split-activation-single-owner.test.ts index 0ac2a9d5f647..263fed966ec9 100644 --- a/src/renderer/src/lib/setup-split-activation-single-owner.test.ts +++ b/src/renderer/src/lib/setup-split-activation-single-owner.test.ts @@ -13,8 +13,9 @@ import { makeCreatedAgentWorktree as makeWorktree } from './worktree-activation- /** * STA-9417, converted from the natural repro: a host-created worktree whose setup script runs in a * split of the (unmounted) primary tab, then the first activation of that worktree. When main's - * split reveal does not reach the window, the activation sweep mints a second tab for the setup - * PTY, so two saved panes own one terminal and one of them freezes. + * split reveal did not reach the window, the activation sweep minted a second tab for the setup + * PTY, so two saved panes owned one terminal and one of them froze. Main's topology push now + * carries the split whether or not the reveal arrives. */ const initialState = useAppStore.getState() @@ -179,6 +180,34 @@ function leafIdsInOrder(node: TerminalPaneLayoutNode | null): string[] { : [...leafIdsInOrder(node.first), ...leafIdsInOrder(node.second)] } +/** Main's push after it binds the setup split: the window mirrors it whether or not the reveal lands. */ +function pushSetupSplitSlice( + worktreeId: string, + primaryPtyId: string, + setupPtyId: string, + publishSeq: number +): void { + useAppStore.getState().applyTerminalTopologySlice({ + hostId: 'local', + worktreeId, + publishSeq, + revision: 1, + tabs: [{ id: TAB_B, ptyId: primaryPtyId, worktreeId, createdAt: 1 }], + layouts: { + [TAB_B]: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: PRIMARY_LEAF }, + second: { type: 'leaf', leafId: SETUP_LEAF } + }, + ptyIdsByLeafId: { [PRIMARY_LEAF]: primaryPtyId, [SETUP_LEAF]: setupPtyId } + } + }, + sleeping: {} + }) +} + function setupSplitReveal( requestId: string, worktreeId: string, @@ -238,6 +267,7 @@ describe('STA-9417: setup split of an unmounted tab, then first activation', () if (revealSplit) { host.reveal()(setupSplitReveal('reveal-setup-split', worktree.id, setupPtyId)) } + pushSetupSplitSlice(worktree.id, primaryPtyId, setupPtyId, 1) await activateFirstTime(worktree.id) const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? [] @@ -252,13 +282,11 @@ describe('STA-9417: setup split of an unmounted tab, then first activation', () await runScenario(true) }) - // STA-9417: fails until the mirror refactor (B2-4) pushes main's split before the sweep runs. - it.fails('ends with one tab and one owner per terminal when the split reveal never arrives', async () => { + it('ends with one tab and one owner per terminal when the split reveal never arrives', async () => { await runScenario(false) }) - // STA-9417: fails until the mirror refactor (B2-4); the odd runs mint the duplicate. - it.fails('stays duplicate-free across 8 create-then-activate cycles, with or without the reveal', async () => { + it('stays duplicate-free across 8 create-then-activate cycles, with or without the reveal', async () => { let duplicates = 0 for (let run = 0; run < 8; run++) { const worktree = makeWorktree() @@ -273,6 +301,7 @@ describe('STA-9417: setup split of an unmounted tab, then first activation', () if (run % 2 === 0) { host.reveal()(setupSplitReveal(`reveal-${run}`, worktree.id, setupPtyId)) } + pushSetupSplitSlice(worktree.id, primaryPtyId, setupPtyId, run + 1) await activateFirstTime(worktree.id) duplicates += [...ownersByPty(worktree.id).values()].filter( (owners) => owners.length > 1 diff --git a/src/renderer/src/lib/terminal-topology-follow.test.ts b/src/renderer/src/lib/terminal-topology-follow.test.ts new file mode 100644 index 000000000000..b365534850a7 --- /dev/null +++ b/src/renderer/src/lib/terminal-topology-follow.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { TerminalTopologySlice } from '../../../shared/terminal-topology-slice' +import { followTerminalTopology } from './terminal-topology-follow' + +const WT = 'repo::/wt' +const initial = useAppStore.getState() + +function slice(publishSeq: number, tabIds: string[]): TerminalTopologySlice { + return { + hostId: 'local', + worktreeId: WT, + publishSeq, + revision: 1, + tabs: tabIds.map((id) => ({ id, ptyId: null, worktreeId: WT, createdAt: 1 })), + layouts: {}, + sleeping: {} + } +} + +function fakeMain(pulled: () => TerminalTopologySlice[]) { + const calls: string[] = [] + let listener: ((slice: TerminalTopologySlice) => void) | undefined + let releasePull!: () => void + const pullGate = new Promise((resolve) => { + releasePull = resolve + }) + const unsubscribe = vi.fn() + return { + calls, + unsubscribe, + releasePull, + push: (next: TerminalTopologySlice) => listener?.(next), + source: { + onTerminalTopologyChanged: (callback: (next: TerminalTopologySlice) => void) => { + calls.push('subscribe') + listener = callback + return unsubscribe + }, + getTerminalTopologySlices: async () => { + calls.push('pull') + await pullGate + return pulled() + } + } + } +} + +const tabIds = () => (useAppStore.getState().tabsByWorktree[WT] ?? []).map((tab) => tab.id) +const apply = (next: TerminalTopologySlice) => + useAppStore.getState().applyTerminalTopologySlice(next) + +afterEach(() => { + useAppStore.setState(initial, true) +}) + +describe('followTerminalTopology', () => { + it('subscribes before pulling and resolves only once the pull is applied', async () => { + const main = fakeMain(() => [slice(2, ['a'])]) + let settled = false + const following = followTerminalTopology(main.source, apply, new AbortController().signal).then( + () => { + settled = true + } + ) + await Promise.resolve() + expect(main.calls).toEqual(['subscribe', 'pull']) + expect(settled).toBe(false) + + main.releasePull() + await following + expect(tabIds()).toEqual(['a']) + }) + + it('keeps a push that raced ahead of an older pull', async () => { + const main = fakeMain(() => [slice(2, ['a'])]) + const following = followTerminalTopology(main.source, apply, new AbortController().signal) + main.push(slice(3, ['a', 'b'])) + main.releasePull() + await following + expect(tabIds()).toEqual(['a', 'b']) + }) + + it('ignores a push delivered after a pull that already held a newer slice', async () => { + const main = fakeMain(() => [slice(5, ['a', 'b'])]) + const following = followTerminalTopology(main.source, apply, new AbortController().signal) + main.releasePull() + await following + main.push(slice(4, ['a'])) + expect(tabIds()).toEqual(['a', 'b']) + main.push(slice(6, ['b'])) + expect(tabIds()).toEqual(['b']) + }) + + it('stops following when the boot is aborted', async () => { + const main = fakeMain(() => []) + const abort = new AbortController() + const following = followTerminalTopology(main.source, apply, abort.signal) + main.releasePull() + await following + abort.abort() + expect(main.unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('keeps following pushes when the startup pull fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const main = fakeMain(() => { + throw new Error('ipc down') + }) + const following = followTerminalTopology(main.source, apply, new AbortController().signal) + main.releasePull() + await following + main.push(slice(1, ['a'])) + expect(tabIds()).toEqual(['a']) + expect(warn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/lib/terminal-topology-follow.ts b/src/renderer/src/lib/terminal-topology-follow.ts new file mode 100644 index 000000000000..ed44cdaf9d78 --- /dev/null +++ b/src/renderer/src/lib/terminal-topology-follow.ts @@ -0,0 +1,29 @@ +import type { TerminalTopologySlice } from '../../../shared/terminal-topology-slice' + +type TerminalTopologySource = { + onTerminalTopologyChanged: (callback: (slice: TerminalTopologySlice) => void) => () => void + getTerminalTopologySlices: () => Promise +} + +/** + * Subscribes, then pulls: a push sent before the subscription is in the pull, and one racing the + * pull is ordered by publishSeq. Follows pushes until `signal` aborts. + */ +export async function followTerminalTopology( + source: TerminalTopologySource, + apply: (slice: TerminalTopologySlice) => void, + signal: AbortSignal +): Promise { + if (signal.aborted) { + return + } + signal.addEventListener('abort', source.onTerminalTopologyChanged(apply), { once: true }) + try { + for (const slice of await source.getTerminalTopologySlices()) { + apply(slice) + } + } catch (error) { + // The hydrated session came from the same store, so the window stays usable; pushes still apply. + console.warn('[terminal-topology] Startup pull failed:', error) + } +} diff --git a/src/renderer/src/startup/startup-runtime-session-hosts.ts b/src/renderer/src/startup/startup-runtime-session-hosts.ts new file mode 100644 index 000000000000..dc071166e050 --- /dev/null +++ b/src/renderer/src/startup/startup-runtime-session-hosts.ts @@ -0,0 +1,12 @@ +import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' + +export async function listRuntimeSessionHostIdsForStartup(): Promise { + try { + return (await window.api.runtimeEnvironments.list()).map((environment) => + toRuntimeExecutionHostId(environment.id) + ) + } catch (err) { + console.warn('Failed to list runtime session hosts for startup:', err) + return [] + } +} diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 5b2137120342..ec118bb7bc88 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -22,6 +22,7 @@ import { createTerminalRestartActions } from '../terminals/terminal-restart-stat import { createTerminalLayoutActions } from '../terminals/terminal-layout-state' import { createTerminalStartupQueueActions } from '../terminals/terminal-startup-queues' import { createWorkspaceTerminalHydrationActions } from '../terminals/workspace-terminal-hydration' +import { createTerminalTopologyMirrorActions } from '../terminals/terminal-topology-mirror' import { createWorkspaceTerminalReconnectActions } from '../terminals/workspace-terminal-reconnect' export const createTerminalSlice: StateCreator = (set, get) => ({ @@ -62,6 +63,7 @@ export const createTerminalSlice: StateCreator contestedHostWorkspaceSessions: {}, contestedPrimaryHostBySessionKey: {}, defaultTerminalTabsAppliedByWorktreeId: {}, + terminalTopologySeqByWorktree: {}, closedTerminalTabTombstonesByTabId: {}, hydrationSucceeded: false, pendingReconnectWorktreeIds: [], @@ -96,5 +98,6 @@ export const createTerminalSlice: StateCreator ...createTerminalLayoutActions(set, get), ...createTerminalStartupQueueActions(set, get), ...createWorkspaceTerminalHydrationActions(set, get), + ...createTerminalTopologyMirrorActions(set), ...createWorkspaceTerminalReconnectActions(set, get) }) diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 4a30b52a2d79..ba07d1ac950c 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -3,6 +3,7 @@ import type { DirectSshLayoutEdit, TerminalState } from './terminal-state' import type { Tab } from '../../../../shared/tab-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../../shared/tui-agent' +import type { TerminalTopologySlice } from '../../../../shared/terminal-topology-slice' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' import type { @@ -285,6 +286,8 @@ export type TerminalActions = { setDeferredSshReconnectTargets: (targetIds: string[]) => void removeDeferredSshReconnectTarget: (targetId: string) => void removeDeferredSshSessionId: (tabId: string) => void + /** Mirrors main's terminal topology for one worktree; a slice older than the last applied is ignored. */ + applyTerminalTopologySlice: (slice: TerminalTopologySlice) => void /** Hydrates canonical rows first, then transfers normalized pane authority post-publication. */ hydrateWorkspaceSession: ( session: WorkspaceSessionState, diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index d0595b8721f8..b2ee0542c5da 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -113,6 +113,8 @@ export type TerminalState = { /** Partition each restored session key was read from, so a write returns its rows there. */ contestedPrimaryHostBySessionKey: Record defaultTerminalTabsAppliedByWorktreeId: Record + /** The publishSeq of main's last applied terminal topology slice per worktree. */ + terminalTopologySeqByWorktree: Record closedTerminalTabTombstonesByTabId: ClosedTerminalTabTombstonesByTabId hydrationSucceeded: boolean pendingReconnectWorktreeIds: string[] diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror-unified-tabs.ts b/src/renderer/src/store/terminals/terminal-topology-mirror-unified-tabs.ts new file mode 100644 index 000000000000..b065e8643f9f --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-topology-mirror-unified-tabs.ts @@ -0,0 +1,119 @@ +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { AppState } from '../types' +import { + dedupeTabOrder, + ensureGroup, + pickNextActiveTab, + sanitizeRecentTabIds +} from '../slices/tab-group-state' + +type UnifiedTabsPatch = Partial< + Pick< + AppState, + 'unifiedTabsByWorktree' | 'groupsByWorktree' | 'activeGroupIdByWorktree' | 'layoutByWorktree' + > +> + +function isTerminalEntryFor(tab: Tab, terminalIds: ReadonlySet): boolean { + return ( + tab.contentType === 'terminal' && (terminalIds.has(tab.entityId) || terminalIds.has(tab.id)) + ) +} + +function withoutTabs(group: TabGroup, removed: ReadonlySet): TabGroup { + const tabOrder = group.tabOrder.filter((id) => !removed.has(id)) + if (tabOrder.length === group.tabOrder.length) { + return group + } + const activeTabId = + group.activeTabId && removed.has(group.activeTabId) + ? pickNextActiveTab(tabOrder, group.recentTabIds, group.activeTabId) + : group.activeTabId + return { + ...group, + tabOrder, + activeTabId, + recentTabIds: sanitizeRecentTabIds(group.recentTabIds, tabOrder) + } +} + +function unifiedTerminalTab(tab: TerminalTab, groupId: string, sortOrder: number): Tab { + return { + id: tab.id, + entityId: tab.id, + groupId, + worktreeId: tab.worktreeId, + contentType: 'terminal', + label: tab.title, + ...(tab.quickCommandLabel?.trim() ? { quickCommandLabel: tab.quickCommandLabel.trim() } : {}), + customLabel: tab.customTitle, + color: tab.color, + sortOrder, + createdAt: tab.createdAt + } +} + +/** Joins terminal rows to unified tabs on `entityId` or `id`; a new row joins the active group. */ +export function mirrorTerminalUnifiedTabs( + state: AppState, + worktreeId: string, + added: readonly TerminalTab[], + removedIds: readonly string[] +): UnifiedTabsPatch { + const current = state.unifiedTabsByWorktree[worktreeId] ?? [] + const removedSet = new Set(removedIds) + const represented = new Set( + current.flatMap((tab) => (tab.contentType === 'terminal' ? [tab.id, tab.entityId] : [])) + ) + const joining = added.filter((tab) => !represented.has(tab.id)) + const removedEntryIds = new Set( + current.filter((tab) => isTerminalEntryFor(tab, removedSet)).map((tab) => tab.id) + ) + if (joining.length === 0 && removedEntryIds.size === 0) { + return {} + } + const ensured = + joining.length > 0 + ? ensureGroup( + state.groupsByWorktree, + state.activeGroupIdByWorktree, + worktreeId, + state.activeGroupIdByWorktree[worktreeId] + ) + : null + const groups = (ensured?.groupsByWorktree ?? state.groupsByWorktree)[worktreeId] ?? [] + const target = ensured?.group + const joined = target + ? joining.map((tab, index) => + unifiedTerminalTab(tab, target.id, target.tabOrder.length + index) + ) + : [] + const nextGroups = groups.map((group) => { + const pruned = withoutTabs(group, removedEntryIds) + if (group.id !== target?.id) { + return pruned + } + const tabOrder = dedupeTabOrder([...pruned.tabOrder, ...joined.map((tab) => tab.id)]) + return { ...pruned, tabOrder, activeTabId: pruned.activeTabId ?? tabOrder[0] ?? null } + }) + return { + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: [...current.filter((tab) => !removedEntryIds.has(tab.id)), ...joined] + }, + groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: nextGroups }, + ...(ensured && ensured.activeGroupIdByWorktree !== state.activeGroupIdByWorktree + ? { + activeGroupIdByWorktree: ensured.activeGroupIdByWorktree, + layoutByWorktree: { + ...state.layoutByWorktree, + [worktreeId]: state.layoutByWorktree[worktreeId] ?? { + type: 'leaf', + groupId: ensured.group.id + } + } + } + : {}) + } +} diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts new file mode 100644 index 000000000000..adf8c23af094 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.test.ts @@ -0,0 +1,380 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { createSessionWriteSubscriber } from '@/lib/session-write-subscriber' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { + TerminalTopologySlice, + TerminalTopologyTabRow +} from '../../../../shared/terminal-topology-slice' + +const WT = 'repo::/wt' +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' +const LEAF_C = '33333333-3333-4333-8333-333333333333' + +const initial = useAppStore.getState() + +function windowTab(id: string, overrides: Partial = {}): TerminalTab { + return { + id, + ptyId: `pty-${id}`, + worktreeId: WT, + title: `Window ${id}`, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides + } +} + +function unified(id: string, overrides: Partial = {}): Tab { + return { + id, + entityId: id, + groupId: 'group', + worktreeId: WT, + contentType: 'terminal', + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides + } +} + +function leafLayout(leafId: string, ptyId: string): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId }, + buffersByLeafId: { [leafId]: 'scrollback' } + } +} + +function row(id: string, overrides: Partial = {}): TerminalTopologyTabRow { + return { id, ptyId: `pty-${id}`, worktreeId: WT, createdAt: 1, ...overrides } +} + +function slice(publishSeq: number, overrides: Partial = {}) { + return { + hostId: 'local', + worktreeId: WT, + publishSeq, + revision: 1, + tabs: [row('a'), row('b')], + layouts: { + a: { root: { type: 'leaf', leafId: LEAF_A }, ptyIdsByLeafId: { [LEAF_A]: 'pty-a' } }, + b: { root: { type: 'leaf', leafId: LEAF_B }, ptyIdsByLeafId: { [LEAF_B]: 'pty-b' } } + }, + sleeping: {}, + ...overrides + } satisfies TerminalTopologySlice +} + +const group: TabGroup = { id: 'group', worktreeId: WT, activeTabId: 'a', tabOrder: ['a', 'b'] } + +function seed(): void { + useAppStore.setState({ + tabsByWorktree: { + [WT]: [ + windowTab('a', { + customTitle: 'Mine', + color: '#f00', + isPinned: true, + generation: 3, + pendingActivationSpawn: 2, + agentLaunchPane: { leafId: LEAF_A, operationId: 'op' } + }), + windowTab('b', { sortOrder: 1 }) + ] + }, + terminalLayoutsByTabId: { a: leafLayout(LEAF_A, 'pty-a'), b: leafLayout(LEAF_B, 'pty-b') }, + ptyIdsByTabId: { a: ['pty-a'], b: ['pty-b'] }, + unifiedTabsByWorktree: { + [WT]: [ + unified('a'), + unified('b', { sortOrder: 1 }), + unified('/wt/readme.md', { contentType: 'editor' }) + ] + }, + groupsByWorktree: { [WT]: [{ ...group, tabOrder: ['a', 'b', '/wt/readme.md'] }] }, + activeGroupIdByWorktree: { [WT]: 'group' } + }) + useAppStore.getState().applyTerminalTopologySlice(slice(1)) +} + +const apply = (next: TerminalTopologySlice) => + useAppStore.getState().applyTerminalTopologySlice(next) +const state = () => useAppStore.getState() + +afterEach(() => { + vi.useRealTimers() + useAppStore.setState(initial, true) +}) + +describe('applyTerminalTopologySlice', () => { + beforeEach(seed) + + it('keeps presentation, liveness and activation state while replacing topology', () => { + const ptyIdsByTabId = state().ptyIdsByTabId + apply( + slice(2, { + tabs: [row('a', { ptyId: 'pty-main', launchAgent: 'codex' }), row('b')], + layouts: { + ...slice(2).layouts, + a: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_C } + }, + ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_C]: 'pty-c' }, + titlesByLeafId: { [LEAF_C]: 'logs' } + } + } + }) + ) + + const tab = state().tabsByWorktree[WT][0] + expect(tab).toMatchObject({ + launchAgent: 'codex', + customTitle: 'Mine', + color: '#f00', + isPinned: true, + generation: 3, + pendingActivationSpawn: 2, + agentLaunchPane: { leafId: LEAF_A, operationId: 'op' }, + // The row's ptyId is the window's live attachment, not main's last binding. + ptyId: 'pty-a' + }) + expect(state().terminalLayoutsByTabId.a).toEqual({ + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_C } + }, + activeLeafId: LEAF_A, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_C]: 'pty-c' }, + titlesByLeafId: { [LEAF_C]: 'logs' }, + buffersByLeafId: { [LEAF_A]: 'scrollback' } + }) + expect(state().ptyIdsByTabId).toBe(ptyIdsByTabId) + }) + + it('clears an optional row field main dropped', () => { + apply(slice(2, { tabs: [row('a', { launchAgent: 'codex' }), row('b')] })) + apply(slice(3)) + expect(state().tabsByWorktree[WT][0]).not.toHaveProperty('launchAgent') + }) + + it('leaves the store untouched by an identical slice and schedules no save', () => { + vi.useFakeTimers() + useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true }) + const persist = vi.fn() + const dispose = createSessionWriteSubscriber({ store: useAppStore, persist }) + vi.advanceTimersByTime(1_000) + persist.mockClear() + const before = state() + + apply(slice(2)) + + for (const key of [ + 'tabsByWorktree', + 'terminalLayoutsByTabId', + 'unifiedTabsByWorktree', + 'groupsByWorktree', + 'sleepingAgentSessionsByPaneKey', + 'ptyIdsByTabId' + ] as const) { + expect(state()[key]).toBe(before[key]) + } + vi.advanceTimersByTime(1_000) + expect(persist).not.toHaveBeenCalled() + dispose() + }) + + it('never echoes a changed slice back as a session save', () => { + vi.useFakeTimers() + useAppStore.setState({ workspaceSessionReady: true, hydrationSucceeded: true }) + const persist = vi.fn() + const dispose = createSessionWriteSubscriber({ store: useAppStore, persist }) + vi.advanceTimersByTime(1_000) + persist.mockClear() + + apply(slice(2, { tabs: [row('a')], layouts: { a: slice(2).layouts.a } })) + vi.advanceTimersByTime(1_000) + expect(persist).not.toHaveBeenCalled() + + // A later local edit still saves. + state().setTabCustomTitle('a', 'renamed') + vi.advanceTimersByTime(1_000) + expect(persist).toHaveBeenCalledTimes(1) + dispose() + }) + + it('keeps every other tab and layout reference when one tab changes', () => { + const before = state() + apply( + slice(2, { + layouts: { + ...slice(2).layouts, + b: { root: { type: 'leaf', leafId: LEAF_B }, ptyIdsByLeafId: { [LEAF_B]: 'pty-b2' } } + } + }) + ) + expect(state().terminalLayoutsByTabId.a).toBe(before.terminalLayoutsByTabId.a) + expect(state().terminalLayoutsByTabId.b.ptyIdsByLeafId).toEqual({ [LEAF_B]: 'pty-b2' }) + expect(state().tabsByWorktree).toBe(before.tabsByWorktree) + }) + + it('removes a tab and its unified entry joined on entityId, leaving other tabs alone', () => { + useAppStore.setState({ + unifiedTabsByWorktree: { + [WT]: [ + unified('a'), + unified('unified-b', { entityId: 'b' }), + unified('/wt/readme.md', { contentType: 'editor' }) + ] + }, + groupsByWorktree: { + [WT]: [ + { + ...group, + activeTabId: 'unified-b', + tabOrder: ['a', 'unified-b', '/wt/readme.md'], + recentTabIds: ['a', 'unified-b'] + } + ] + } + }) + + apply(slice(2, { tabs: [row('a')], layouts: { a: slice(2).layouts.a } })) + + expect(state().tabsByWorktree[WT].map((tab) => tab.id)).toEqual(['a']) + expect(state().terminalLayoutsByTabId).not.toHaveProperty('b') + expect(state().unifiedTabsByWorktree[WT].map((tab) => tab.id)).toEqual(['a', '/wt/readme.md']) + expect(state().groupsByWorktree[WT][0]).toMatchObject({ + tabOrder: ['a', '/wt/readme.md'], + activeTabId: 'a', + recentTabIds: ['a'] + }) + }) + + it('appends a new tab to the active group without activating it', () => { + apply( + slice(2, { + tabs: [...slice(2).tabs, row('c', { defaultTitle: 'Terminal 3', launchAgent: 'claude' })], + layouts: { + ...slice(2).layouts, + c: { root: { type: 'leaf', leafId: LEAF_C }, ptyIdsByLeafId: { [LEAF_C]: 'pty-c' } } + } + }) + ) + + expect(state().tabsByWorktree[WT][2]).toEqual({ + id: 'c', + ptyId: 'pty-c', + worktreeId: WT, + createdAt: 1, + defaultTitle: 'Terminal 3', + launchAgent: 'claude', + title: 'Terminal 3', + customTitle: null, + color: null, + sortOrder: 2 + }) + expect(state().terminalLayoutsByTabId.c).toMatchObject({ + root: { type: 'leaf', leafId: LEAF_C }, + activeLeafId: LEAF_C + }) + expect(state().unifiedTabsByWorktree[WT].at(-1)).toMatchObject({ + id: 'c', + entityId: 'c', + groupId: 'group', + contentType: 'terminal' + }) + expect(state().groupsByWorktree[WT][0]).toMatchObject({ + activeTabId: 'a', + tabOrder: ['a', 'b', '/wt/readme.md', 'c'] + }) + expect(state().ptyIdsByTabId).not.toHaveProperty('c') + }) + + it('does not add a unified entry a tab already has under another id', () => { + useAppStore.setState({ + unifiedTabsByWorktree: { + [WT]: [unified('a'), unified('b'), unified('u-c', { entityId: 'c' })] + } + }) + apply(slice(2, { tabs: [...slice(2).tabs, row('c')] })) + expect(state().unifiedTabsByWorktree[WT].map((tab) => tab.id)).toEqual(['a', 'b', 'u-c']) + }) + + it('leaves a runtime-hosted tab under the same worktree id untouched', () => { + const runtimeTab = windowTab('remote-tab', { ptyId: 'remote:env@@handle' }) + useAppStore.setState({ + tabsByWorktree: { [WT]: [...state().tabsByWorktree[WT], runtimeTab] }, + unifiedTabsByWorktree: { + [WT]: [ + ...state().unifiedTabsByWorktree[WT], + unified('remote-tab', { executionHostId: 'runtime:env' }) + ] + } + }) + apply(slice(2, { tabs: [row('a')], layouts: { a: slice(2).layouts.a } })) + expect(state().tabsByWorktree[WT].map((tab) => tab.id)).toEqual(['a', 'remote-tab']) + expect(state().tabsByWorktree[WT][1]).toBe(runtimeTab) + expect(state().unifiedTabsByWorktree[WT].map((tab) => tab.id)).toContain('remote-tab') + }) + + it('replaces only this worktree’s sleeping records', () => { + const record = (paneKey: string, worktreeId: string): SleepingAgentSessionRecord => ({ + paneKey, + worktreeId, + agent: 'codex', + providerSession: { key: 'session_id', id: paneKey }, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1 + }) + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: { + [`a:${LEAF_A}`]: record(`a:${LEAF_A}`, WT), + 'other:leaf': record('other:leaf', 'repo::/other') + } + }) + apply(slice(2, { sleeping: { [`b:${LEAF_B}`]: record(`b:${LEAF_B}`, WT) } })) + expect(Object.keys(state().sleepingAgentSessionsByPaneKey).sort()).toEqual([ + `b:${LEAF_B}`, + 'other:leaf' + ]) + }) + + it('ignores a slice older than the last one applied', () => { + apply(slice(5, { tabs: [row('a')], layouts: { a: slice(5).layouts.a } })) + const before = state() + apply(slice(4)) + expect(state()).toBe(before) + expect(state().tabsByWorktree[WT].map((tab) => tab.id)).toEqual(['a']) + }) + + it('leaves other worktrees and non-terminal tabs untouched', () => { + const other = windowTab('x', { worktreeId: 'repo::/other' }) + useAppStore.setState({ + tabsByWorktree: { ...state().tabsByWorktree, 'repo::/other': [other] } + }) + apply(slice(2, { tabs: [], layouts: {} })) + expect(state().tabsByWorktree['repo::/other'][0]).toBe(other) + expect(state().tabsByWorktree[WT]).toEqual([]) + expect(state().unifiedTabsByWorktree[WT].map((tab) => tab.id)).toEqual(['/wt/readme.md']) + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-topology-mirror.ts b/src/renderer/src/store/terminals/terminal-topology-mirror.ts new file mode 100644 index 000000000000..f77e3d70285f --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-topology-mirror.ts @@ -0,0 +1,172 @@ +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { structuralValuesEqual } from '../../../../shared/structural-value-equality' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { + TerminalTopologyLayout, + TerminalTopologySlice, + TerminalTopologyTabRow +} from '../../../../shared/terminal-topology-slice' +import { resolvePtyBoundActiveLeafId } from '@/components/terminal-pane/terminal-layout-leaf-ids' +import { sameStringRecord, terminalLayoutNodeEqual } from '@/lib/terminal-layout-equality' +import type { AppState } from '../types' +import { emptyLayoutSnapshot } from '../slices/terminal-helpers' +import type { TerminalSlice, TerminalStoreSet } from './terminal-state' +import { mirrorTerminalUnifiedTabs } from './terminal-topology-mirror-unified-tabs' + +const OPTIONAL_ROW_FIELDS = [ + 'launchAgent', + 'defaultTitle', + 'shellOverride', + 'startupCwd', + 'forceHostRuntime', + 'quickCommandLabel' +] as const satisfies readonly (keyof TerminalTopologyTabRow)[] + +type _UnmirroredRowField = Exclude< + keyof TerminalTopologyTabRow, + 'id' | 'ptyId' | 'worktreeId' | 'createdAt' | (typeof OPTIONAL_ROW_FIELDS)[number] +> +void (true satisfies [_UnmirroredRowField] extends [never] ? true : never) + +/** Main's row fields replace the window's; presentation stays, and so does `ptyId`, the live attachment (D1). */ +function mirrorTabRow(current: TerminalTab, row: TerminalTopologyTabRow): TerminalTab { + const next: TerminalTab = { ...current, ...row, ptyId: current.ptyId } + for (const field of OPTIONAL_ROW_FIELDS) { + if (!(field in row)) { + delete next[field] + } + } + return structuralValuesEqual(next, current) ? current : next +} + +/** A tab main created; its ptyId seeds the window's attachment, as a revealed tab's would. */ +function newTabRow(row: TerminalTopologyTabRow, sortOrder: number): TerminalTab { + return { + ...row, + title: row.defaultTitle ?? 'Terminal', + customTitle: null, + color: null, + sortOrder + } +} + +function mirrorLayout( + current: TerminalLayoutSnapshot | undefined, + layout: TerminalTopologyLayout +): TerminalLayoutSnapshot { + if ( + current && + terminalLayoutNodeEqual(current.root, layout.root) && + sameStringRecord(current.ptyIdsByLeafId, layout.ptyIdsByLeafId) && + sameStringRecord(current.titlesByLeafId, layout.titlesByLeafId) + ) { + return current + } + const { + ptyIdsByLeafId: _ptyIds, + titlesByLeafId: _titles, + ...presentation + } = current ?? emptyLayoutSnapshot() + const activeLeafId = current + ? presentation.activeLeafId + : resolvePtyBoundActiveLeafId({ + root: layout.root, + activeLeafId: null, + ptyIdsByLeafId: layout.ptyIdsByLeafId + }) + return { ...presentation, ...layout, activeLeafId } +} + +function mirrorSleepingRecords( + current: Record, + slice: TerminalTopologySlice +): Record { + const others = Object.entries(current).filter( + ([, record]) => record.worktreeId !== slice.worktreeId + ) + const next = { ...Object.fromEntries(others), ...slice.sleeping } + return structuralValuesEqual(next, current) ? current : next +} + +/** Tabs a `runtime:` host publishes share the worktree id but never ride main's slice. */ +function isRuntimeHostedTab(state: AppState, worktreeId: string, tabId: string): boolean { + const entry = state.unifiedTabsByWorktree[worktreeId]?.find( + (tab) => tab.contentType === 'terminal' && (tab.entityId === tabId || tab.id === tabId) + ) + return parseExecutionHostId(entry?.executionHostId)?.kind === 'runtime' +} + +/** + * Main's slice replaces the worktree's terminal topology; everything else is the window's. + * Unchanged rows and layouts keep their identity, so an identical slice changes only the seq. + */ +export function mirrorTerminalTopologySlice( + state: AppState, + slice: TerminalTopologySlice +): Partial { + const { worktreeId } = slice + const currentTabs = state.tabsByWorktree[worktreeId] ?? [] + const rowById = new Map(slice.tabs.map((row) => [row.id, row])) + const kept = currentTabs.flatMap((tab) => { + const row = rowById.get(tab.id) + if (row) { + return [mirrorTabRow(tab, row)] + } + return isRuntimeHostedTab(state, worktreeId, tab.id) ? [tab] : [] + }) + const currentIds = new Set(currentTabs.map((tab) => tab.id)) + const added = slice.tabs + .filter((row) => !currentIds.has(row.id)) + .map((row, index) => newTabRow(row, kept.length + index)) + const tabs = [...kept, ...added] + const tabsChanged = + tabs.length !== currentTabs.length || tabs.some((tab, index) => tab !== currentTabs[index]) + const keptIds = new Set(tabs.map((tab) => tab.id)) + const removedIds = currentTabs.filter((tab) => !keptIds.has(tab.id)).map((tab) => tab.id) + + const layouts = { ...state.terminalLayoutsByTabId } + for (const [tabId, layout] of Object.entries(slice.layouts)) { + layouts[tabId] = mirrorLayout(layouts[tabId], layout) + } + for (const tab of added) { + layouts[tab.id] ??= emptyLayoutSnapshot() + } + for (const tabId of removedIds) { + delete layouts[tabId] + } + const layoutsChanged = + added.length + removedIds.length > 0 || + Object.keys(slice.layouts).some( + (tabId) => layouts[tabId] !== state.terminalLayoutsByTabId[tabId] + ) + + const sleeping = mirrorSleepingRecords(state.sleepingAgentSessionsByPaneKey, slice) + return { + ...(tabsChanged ? { tabsByWorktree: { ...state.tabsByWorktree, [worktreeId]: tabs } } : {}), + ...(layoutsChanged ? { terminalLayoutsByTabId: layouts } : {}), + ...(sleeping !== state.sleepingAgentSessionsByPaneKey + ? { sleepingAgentSessionsByPaneKey: sleeping } + : {}), + ...mirrorTerminalUnifiedTabs(state, worktreeId, added, removedIds), + terminalTopologySeqByWorktree: { + ...state.terminalTopologySeqByWorktree, + [worktreeId]: slice.publishSeq + } + } +} + +export function createTerminalTopologyMirrorActions( + set: TerminalStoreSet +): Pick { + return { + applyTerminalTopologySlice: (slice) => { + set((s) => + // Pushes and the startup pull race; the higher publishSeq is the newer slice. + slice.publishSeq > (s.terminalTopologySeqByWorktree[slice.worktreeId] ?? 0) + ? mirrorTerminalTopologySlice(s, slice) + : s + ) + } + } +} diff --git a/src/renderer/src/web/preload-api/web-workspace-session-api.ts b/src/renderer/src/web/preload-api/web-workspace-session-api.ts index 553b065c9923..f8108c75d610 100644 --- a/src/renderer/src/web/preload-api/web-workspace-session-api.ts +++ b/src/renderer/src/web/preload-api/web-workspace-session-api.ts @@ -82,7 +82,10 @@ export function createWebWorkspaceSessionApi(): Partial { ) }, // Why a no-op: web closes reach the host through its session-tab and terminal close RPCs. - closeTerminalSurface: async () => {}, + closeTerminalSurface: async () => ({}), + // Why empty: web clients follow the host's session tabs, not main's topology push. + getTerminalTopologySlices: async () => [], + onTerminalTopologyChanged: () => () => {}, // localStorage writes synchronously, so there is no deferred web flush. flush: async () => {}, readTerminalScrollback: () => null, diff --git a/src/shared/terminal-topology-slice.ts b/src/shared/terminal-topology-slice.ts index 79c0cc0b26a6..cc475ad0c458 100644 --- a/src/shared/terminal-topology-slice.ts +++ b/src/shared/terminal-topology-slice.ts @@ -33,3 +33,6 @@ export type TerminalTopologySlice = { layouts: Record sleeping: Record } + +/** On a reply whose write main publishes: the publishSeq of the push holding it. */ +export type TerminalTopologyReply = { publishSeq?: number }