From eaa1ee131d65a13ca6202abcf33c7a09722ed4e3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:25:49 -0400 Subject: [PATCH] A CLI-started terminal shows once in its workspace, and a pane close main has no pane for closes in the window (dark, layout-owner build only) (#30391) * fix(layout): a workspace only main held shows main's tab on the window's first visit (dark, layout-owner build only) * fix(layout): a live terminal no pane shows goes through main, which names the tab it holds; a pane close main has no pane for closes here (dark, layout-owner build only) * test: no type assertions in the CLI-first tests * refactor: the switch-off build keeps today's live-terminal mint as it was * review: frame sync under the line limit; the first-visit test names exactly main's tabs * fix(layout): adopt takes the recorded pane only while it is unbound * fix(layout): a refused live-terminal adoption is declined, not counted as shown --- src/main/runtime/runtime-layout-apply.ts | 2 +- .../layout-owner-pane-close-command.ts | 8 +- .../layout-owner-pane-close.test.ts | 14 ++ ...gent-live-surface-adoption-cli-tab.test.ts | 223 ++++++++++++++++++ .../worktree-agent-live-surface-adoption.ts | 42 +++- ...workspace-layout-frame-drawn-workspaces.ts | 58 +++++ .../workspace-layout-frame-sync.test.ts | 26 +- .../runtime/workspace-layout-frame-sync.ts | 35 +-- ...erminal-tab-create-through-layout-owner.ts | 42 ++++ .../rpc-contract/workspace-layout-params.ts | 4 + .../workspace-layout-command-types.ts | 3 + .../workspace-layout-tab-commands.ts | 12 + ...workspace-layout-terminal-adoption.test.ts | 81 +++++++ .../workspace-layout-terminal-adoption.ts | 40 ++++ ...cli-first-worktree-single-terminal.spec.ts | 124 ++++++++++ 15 files changed, 682 insertions(+), 32 deletions(-) create mode 100644 src/renderer/src/lib/worktree-agent-live-surface-adoption-cli-tab.test.ts create mode 100644 src/renderer/src/runtime/workspace-layout-frame-drawn-workspaces.ts create mode 100644 src/shared/workspace-layout/workspace-layout-terminal-adoption.test.ts create mode 100644 src/shared/workspace-layout/workspace-layout-terminal-adoption.ts create mode 100644 tests/e2e/cli-first-worktree-single-terminal.spec.ts diff --git a/src/main/runtime/runtime-layout-apply.ts b/src/main/runtime/runtime-layout-apply.ts index 15f4e9157b1..2606f490300 100644 --- a/src/main/runtime/runtime-layout-apply.ts +++ b/src/main/runtime/runtime-layout-apply.ts @@ -76,7 +76,7 @@ export async function applyWindowLayoutCommand( markAgentLaunchesClosedByUser(command.workspace, { kind: 'tab', tabId }) } const reply = await store.applyLayoutCommand({ ...command, force: true }, holders) - // Why: a workspace main lacks records nothing; today's close records it in the routed partition. + // Why: a workspace main lacks has no partition record to write; the window already removed the tab. return reply.ok && reply.result.alreadyClosed ? { ok: false, code: 'workspace_not_found' } : reply diff --git a/src/renderer/src/components/terminal-pane/layout-owner-pane-close-command.ts b/src/renderer/src/components/terminal-pane/layout-owner-pane-close-command.ts index 26fda417b03..b587276d1ba 100644 --- a/src/renderer/src/components/terminal-pane/layout-owner-pane-close-command.ts +++ b/src/renderer/src/components/terminal-pane/layout-owner-pane-close-command.ts @@ -16,7 +16,8 @@ export function sendPaneCloseToLayoutOwner( asToday: () => void /** Main refused the pane as its last. */ lastPane: () => void - /** Main closed it but no frame is coming (the stream is down): close it here. */ + /** Main closed it but no frame is coming (the stream is down, or main held no such pane): + * close it here. */ closeHere: () => void } ): boolean { @@ -26,8 +27,9 @@ export function sendPaneCloseToLayoutOwner( pane.worktreeId, { type: 'closePane', tabId, leafId, ...(pane.kind === 'exit' && { automatic: true }) }, { - apply: () => { - if (!layoutFramesFlowing()) { + apply: (result) => { + // Already gone from main's copy (a workspace it lacks): no frame will drop it. + if (result.alreadyClosed || !layoutFramesFlowing()) { forget() on.closeHere() } diff --git a/src/renderer/src/components/terminal-pane/layout-owner-pane-close.test.ts b/src/renderer/src/components/terminal-pane/layout-owner-pane-close.test.ts index f728e50c679..7f564285045 100644 --- a/src/renderer/src/components/terminal-pane/layout-owner-pane-close.test.ts +++ b/src/renderer/src/components/terminal-pane/layout-owner-pane-close.test.ts @@ -83,6 +83,20 @@ it('a layout-owner build closes the pane itself when main closed it and no frame expect(mocks.commitTerminalSurfaceClose).not.toHaveBeenCalled() }) +it('a layout-owner build closes the pane itself when main holds no such pane, so no frame drops it', async () => { + mocks.layoutOwnerIsMain = true + const p = await preparePendingSplitClose() + ;(await import('@/runtime/layout-frame-position')).noteLayoutFramePosition(1) + + p.actions.executeClosePane(1) + mocks.sendLayoutCommandToOwner.mock.calls[0]?.[2].apply({ alreadyClosed: true }) + + const intents = await import('@/runtime/layout-owner-pane-intents') + expect(intents.takePaneCloseIntent('tab-parent', LEAF)).toBeNull() + expect(p.transports.has(1)).toBe(false) + expect(mocks.commitTerminalSurfaceClose).not.toHaveBeenCalled() +}) + it('a layout-owner build closes as today, forgetting its close, when main lacks the pane', async () => { mocks.layoutOwnerIsMain = true const p = await preparePendingSplitClose() diff --git a/src/renderer/src/lib/worktree-agent-live-surface-adoption-cli-tab.test.ts b/src/renderer/src/lib/worktree-agent-live-surface-adoption-cli-tab.test.ts new file mode 100644 index 00000000000..a0fdd6a7940 --- /dev/null +++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption-cli-tab.test.ts @@ -0,0 +1,223 @@ +// A CLI-started terminal the window never mounted reads as unowned to the host's census. With the +// layout-owner switch on, every path that finds it shows exactly main's one tab for it. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands' +import type { LayoutCommand } from '../../../shared/workspace-layout/workspace-layout-command-types' +import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load' +import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' +import type { WorkspaceLayoutModel } from '../../../shared/workspace-layout/workspace-layout-model' +import { publishWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' +import { + addWorkspace, + emptySession, + GIT_KEY, + leaf +} from '../../../shared/workspace-layout/workspace-layout-session.test-fixture' +import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' +import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture' +import { provideLayoutFrameTerminalPanes } from '../runtime/layout-frame-terminal-panes' +import { + openWorkspaceLayoutFrameSync, + type WorkspaceLayoutFrameSync +} from '../runtime/workspace-layout-frame-sync' +import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness' +import { createTestStore } from '../store/slices/store-test-helpers' +import { adoptLiveWorkspacePtySurfaces } from './worktree-agent-live-surface-adoption' +import { runWorktreeAgentActivationGate } from './worktree-agent-activation-gate' +import type { LiveTerminalSurfaceOwnerIndex } from './worktree-live-terminal-surface-owners' + +vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) + +type StreamDouble = { + onFrame: ((frame: WorkspaceLayoutChangeFrame) => void) | null + onStatus: ((status: { state: string }) => void) | null +} +const stream = vi.hoisted((): StreamDouble => ({ onFrame: null, onStatus: null })) +vi.mock('../runtime/runtime-layout-client', () => ({ + subscribeRuntimeLayout: ( + _target: unknown, + _params: unknown, + onFrame: (frame: WorkspaceLayoutChangeFrame) => void, + onStatus: (status: { state: string }) => void + ) => { + stream.onFrame = onFrame + stream.onStatus = onStatus + return { close: () => {} } + } +})) + +/** Main: applies the window's layout commands to its own model, publishing when the stream is up. */ +const main = vi.hoisted((): { apply: ((command: LayoutCommand) => unknown) | null } => ({ + apply: null +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: async (_target: unknown, method: string, params: { command: LayoutCommand }) => { + if (method !== 'layout.apply' || !main.apply) { + throw new Error(`unexpected ${method}`) + } + return main.apply(params.command) + } +})) + +createStoreSessionMockApi() + +const CLI_PTY = `${GIT_KEY}@@c1100001` +const CLI_PANE = `tab-cli:${leaf(1)}` + +function cliSession(bound = true): WorkspaceSessionState { + return addWorkspace(emptySession(), GIT_KEY, [ + { id: 'group-cli', tabs: [{ id: 'tab-cli', leaves: [[leaf(1), bound ? CLI_PTY : undefined]] }] } + ]) +} + +const unowned: LiveTerminalSurfaceOwnerIndex = new Map([ + [CLI_PTY, { unowned: true, recorded: { paneKey: CLI_PANE, ptyId: CLI_PTY, tabId: 'tab-cli' } }] +]) + +const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)) + +function frameOf(model: WorkspaceLayoutModel): WorkspaceLayoutChangeFrame { + return { + type: 'snapshot', + workspaces: Object.entries(model.workspaces).map(([key, workspace]) => ({ + key, + layout: publishWorkspaceLayout(workspace, 'local') + })) + } +} + +describe('a CLI terminal the window never mounted, layout-owner build', () => { + let sync: WorkspaceLayoutFrameSync | null = null + let model: WorkspaceLayoutModel + let streamUp: boolean + + beforeEach(() => { + sync?.close() + sync = null + streamUp = true + model = loadWorkspaceLayoutWithoutReport('local', cliSession(), nameBasedLoadContext()).layout + main.apply = (command) => { + const applied = applyLayoutCommand(model, command, { + mintId: () => 'minted-tab', + mintLeafId: () => leaf(9), + now: () => 1 + }) + if (!applied.ok) { + return { ok: false, code: applied.code } + } + model = applied.model + if (streamUp) { + stream.onFrame!(frameOf(model)) + } + return { ok: true, result: applied.result } + } + }) + + async function bootWindow() { + const store = createTestStore() + await bootWindowFromPartitions(store, [{ hostId: 'local', session: emptySession() }]) + sync = openWorkspaceLayoutFrameSync(store) + provideLayoutFrameTerminalPanes(sync) + sync.start() + return store + } + + const terminalTabIds = (store: Awaited>) => + (store.getState().tabsByWorktree[GIT_KEY] ?? []).map((tab) => tab.id) + const mainTabIds = () => (model.workspaces[GIT_KEY]?.tabs ?? []).map((tab) => tab.id) + + it('the stream is closed during the visit: main names its tab, and the reopened stream draws it', async () => { + const store = await bootWindow() + stream.onFrame!(frameOf(model)) + await flushMicrotasks() + stream.onStatus!({ state: 'lost' }) + streamUp = false + store.getState().ensureWorktreeRootGroup(GIT_KEY) + await flushMicrotasks() + expect(terminalTabIds(store)).toEqual([]) + const createTab = vi.spyOn(store.getState(), 'createTab') + + await adoptLiveWorkspacePtySurfaces(store.getState, GIT_KEY, [CLI_PTY], async () => unowned) + await flushMicrotasks() + + expect(createTab).not.toHaveBeenCalled() + expect(mainTabIds()).toEqual(['tab-cli']) + stream.onStatus!({ state: 'connecting' }) + streamUp = true + stream.onFrame!(frameOf(model)) + await flushMicrotasks() + expect(terminalTabIds(store)).toEqual(['tab-cli']) + }) + + it('main’s frame arrives after the census: main names its tab, and that frame draws it', async () => { + const store = await bootWindow() + const before = loadWorkspaceLayoutWithoutReport('local', emptySession(), nameBasedLoadContext()) + stream.onFrame!(frameOf(before.layout)) + store.getState().ensureWorktreeRootGroup(GIT_KEY) + // Main admitted the CLI pane before its spawn bound it; no frame of it has arrived yet. + model = loadWorkspaceLayoutWithoutReport( + 'local', + cliSession(false), + nameBasedLoadContext() + ).layout + streamUp = false + const createTab = vi.spyOn(store.getState(), 'createTab') + + await adoptLiveWorkspacePtySurfaces(store.getState, GIT_KEY, [CLI_PTY], async () => unowned) + await flushMicrotasks() + + expect(createTab).not.toHaveBeenCalled() + expect(mainTabIds()).toEqual(['tab-cli']) + streamUp = true + stream.onFrame!(frameOf(model)) + await flushMicrotasks() + expect(terminalTabIds(store)).toEqual(['tab-cli']) + }) + + it('a background wake on a workspace the window does not hold: the visit shows main’s one tab', async () => { + const store = await bootWindow() + stream.onFrame!(frameOf(model)) + await flushMicrotasks() + const createTab = vi.spyOn(store.getState(), 'createTab') + + const outcome = await runWorktreeAgentActivationGate(GIT_KEY, { + getState: store.getState, + awaitReady: async () => true, + listSessions: async () => [ + { id: CLI_PTY, cwd: '/', title: '', worktreeId: GIT_KEY, agentOwnership: 'absent' } + ], + listSurfaceOwners: async () => unowned, + hasStructuredSession: async () => false, + resume: () => 0 + }) + await flushMicrotasks() + + expect(outcome).toBe('adopted') + expect(createTab).not.toHaveBeenCalled() + expect(mainTabIds()).toEqual(['tab-cli']) + store.getState().ensureWorktreeRootGroup(GIT_KEY) + await flushMicrotasks() + expect(terminalTabIds(store)).toEqual(['tab-cli']) + }) + + it('main refusing the adoption (a WSL move) leaves the terminal declined, not shown, and makes no tab', async () => { + const store = await bootWindow() + stream.onFrame!(frameOf(model)) + await flushMicrotasks() + main.apply = () => ({ ok: false, code: 'wsl_move_in_progress' }) + const createTab = vi.spyOn(store.getState(), 'createTab') + + const adoption = await adoptLiveWorkspacePtySurfaces( + store.getState, + GIT_KEY, + [CLI_PTY], + async () => unowned + ) + + expect(adoption).toEqual({ surfaced: false, declinedPtyIds: [CLI_PTY] }) + expect(createTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts index f623067239a..e12f15a15f0 100644 --- a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts +++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts @@ -2,6 +2,8 @@ import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import { parsePaneKey } from '../../../shared/stable-pane-id' import { worktreeIdsEqual } from '../../../shared/worktree/id' import type { useAppStore } from '@/store' +import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route' +import { adoptLiveTerminalThroughLayoutOwner } from '@/store/terminals/terminal-tab-create-through-layout-owner' import { layoutFramesHoldTerminalPane } from '@/runtime/layout-frame-terminal-panes' import { resolveTerminalTabPtyOwnership } from './terminal-tab-for-pty-id' import type { @@ -9,16 +11,17 @@ import type { LiveTerminalSurfaceOwnerIndex } from './worktree-live-terminal-surface-owners' -export type LiveSurfaceAdoptionStore = Pick< - ReturnType, - | 'createTab' - | 'ptyIdsByTabId' - | 'setTabLayout' - | 'tabsByWorktree' - | 'terminalLayoutsByTabId' - | 'updateTabPtyId' - | 'replaceTerminalLayoutPanePtyId' -> +export type LiveSurfaceAdoptionStore = WorktreeOperationRouteState & + Pick< + ReturnType, + | 'createTab' + | 'ptyIdsByTabId' + | 'setTabLayout' + | 'tabsByWorktree' + | 'terminalLayoutsByTabId' + | 'updateTabPtyId' + | 'replaceTerminalLayoutPanePtyId' + > function layoutContainsLeaf( root: LiveSurfaceAdoptionStore['terminalLayoutsByTabId'][string]['root'], @@ -201,6 +204,25 @@ export async function adoptLiveWorkspacePtySurfaces( } continue } + // Switch point: main shows it unless this window holds its recorded pane; a refusal leaves + // it without one, so it is declined rather than counted as shown. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + const outcome = + owner.recorded && bindToRecordedSurface(getState(), worktreeId, owner.recorded) + ? 'shown' + : await adoptLiveTerminalThroughLayoutOwner(getState, worktreeId, { + ptyId, + ...(owner.recorded ? { recordedPaneKey: owner.recorded.paneKey } : {}) + }) + if (outcome === 'shown') { + surfaced = true + continue + } + if (outcome === 'refused') { + declinedPtyIds.push(ptyId) + continue + } + } surfaced = true if (owner.recorded && bindToRecordedSurface(getState(), worktreeId, owner.recorded)) { continue diff --git a/src/renderer/src/runtime/workspace-layout-frame-drawn-workspaces.ts b/src/renderer/src/runtime/workspace-layout-frame-drawn-workspaces.ts new file mode 100644 index 00000000000..93a59cbbefe --- /dev/null +++ b/src/renderer/src/runtime/workspace-layout-frame-drawn-workspaces.ts @@ -0,0 +1,58 @@ +// Which workspaces main's layout frames draw in the window, when the window starts holding one, +// and which terminal panes a frame shows. + +import type { AppState } from '@/store/types' +import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' +import { pairedServerMirrors } from './paired-server-workspace' + +type HeldWorkspacesStore = { + getState: () => AppState + subscribe: (listener: (state: AppState) => void) => () => void +} + +/** Which workspaces frames draw: ones the window holds that no paired server mirrors. */ +export function framesDrawWorkspace(state: AppState): (key: string) => boolean { + const mirrored = pairedServerMirrors(state) + return (key) => + (key in state.groupsByWorktree || key in state.unifiedTabsByWorktree) && !mirrored(key) +} + +/** + * Calls `onDrawn` when frames start drawing one of `unshown` (main's workspaces the window does not + * show): the window's first visit to a workspace only main held, after which no frame need follow. + */ +export function onWorkspaceStartsDrawing( + store: HeldWorkspacesStore, + unshown: () => string[], + onDrawn: () => void +): () => void { + let held = store.getState() + return store.subscribe((state) => { + if ( + state.groupsByWorktree === held.groupsByWorktree && + state.unifiedTabsByWorktree === held.unifiedTabsByWorktree + ) { + return + } + held = state + const drawn = framesDrawWorkspace(state) + if (unshown().some(drawn)) { + onDrawn() + } + }) +} + +/** Whether a frame shows this terminal in this pane. An unbound pane does not count. */ +export function layoutHoldsBoundPane( + layout: PublishedWorkspaceLayout | undefined, + { tabId, leafId, ptyId }: { tabId: string; leafId: string; ptyId: string } +): boolean { + return ( + layout?.tabs.some( + (tab) => + tab.entityId === tabId && + (tab.terminal?.panes.some((pane) => pane.leafId === leafId && pane.ptyId === ptyId) ?? + false) + ) ?? false + ) +} diff --git a/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts b/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts index d48e13f484c..892a66cb0bb 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-sync.test.ts @@ -14,7 +14,10 @@ import { type PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' -import { GIT_KEY } from '../../../shared/workspace-layout/workspace-layout-session.test-fixture' +import { + emptySession, + GIT_KEY +} from '../../../shared/workspace-layout/workspace-layout-session.test-fixture' import { buildOwnedEditorFileId } from '../store/slices/editor/file-ids/editor-file-ids' import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture' import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness' @@ -399,6 +402,27 @@ describe('the window drawn from layout frames', () => { sync.close() }) + it('shows a workspace only main held (a CLI-started tab) once the window first opens it', async () => { + const store = createTestStore() + await bootWindowFromPartitions(store, [{ hostId: 'local', session: emptySession() }]) + const sync = openWorkspaceLayoutFrameSync(store) + const layouts = published('local', localDesktopSession()) + stream.onFrame!(snapshot(layouts)) + sync.start() + await flushMicrotasks() + expect(store.getState().tabsByWorktree[GIT_KEY]).toBeUndefined() + // The first visit gives the workspace its empty root group; no frame arrives after it. + store.getState().ensureWorktreeRootGroup(GIT_KEY) + await flushMicrotasks() + // Exactly main's terminal tabs: the visit adds none of its own. + const mainTerminals = layouts[GIT_KEY]!.tabs.flatMap((tab) => + tab.terminal ? [tab.entityId] : [] + ) + expect(mainTerminals).toContain('tab-shell') + expect(store.getState().tabsByWorktree[GIT_KEY]?.map((tab) => tab.id)).toEqual(mainTerminals) + sync.close() + }) + it('started before its snapshot, shows the snapshot when it arrives', async () => { const store = createTestStore() await bootWindowFromPartitions(store, [{ hostId: 'local', session: localDesktopSession() }]) diff --git a/src/renderer/src/runtime/workspace-layout-frame-sync.ts b/src/renderer/src/runtime/workspace-layout-frame-sync.ts index 8eb575081bc..2a365c811bc 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-sync.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-sync.ts @@ -1,7 +1,7 @@ // The window's layout slices drawn from main's layout stream (design phase E2b), in a layout-owner // build only. Main publishes every workspace it holds; this window shows a frame's workspace when // it writes that workspace to a partition this machine owns (a paired server's stay the mirror's) -// and already holds it (startup restore keeps only workspaces the catalog knows). +// and holds it (startup restore keeps only workspaces the catalog knows; a first visit adds one). // // A tab the window drew, closed or moved before main answered stays as drawn until the answer is // in (layout-owner-drawn-tab-holds); everything else shows as each frame arrives. @@ -16,7 +16,11 @@ import { terminalLayoutsPatch } from '@/store/workspace-layout-frame-store-patch' import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' -import { pairedServerMirrors } from './paired-server-workspace' +import { + framesDrawWorkspace, + layoutHoldsBoundPane, + onWorkspaceStartsDrawing +} from './workspace-layout-frame-drawn-workspaces' import { splitWorkspaceSessionByHost } from '@/lib/workspace-session-host-split' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import type { TerminalTabTitleUpdate } from '@/store/slices/terminal-tab-title-batch' @@ -48,6 +52,7 @@ import { subscribeRuntimeLayout } from './runtime-layout-client' type FrameSyncStore = { getState: () => AppState setState: (patch: Partial) => void + subscribe: (listener: (state: AppState) => void) => () => void } export type WorkspaceLayoutFrameSync = LayoutFrameTerminalQueries & { @@ -110,13 +115,6 @@ function liveTitleUpdates( return updates } -/** Which workspaces frames draw: ones the window holds that no paired server mirrors. */ -function framesDrawWorkspace(state: AppState): (key: string) => boolean { - const mirrored = pairedServerMirrors(state) - return (key) => - (key in state.groupsByWorktree || key in state.unifiedTabsByWorktree) && !mirrored(key) -} - export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLayoutFrameSync { let cache: WorkspaceLayoutCache = EMPTY_WORKSPACE_LAYOUT_CACHE /** Workspace key → the frame the store shows. */ @@ -303,6 +301,14 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa scheduleFlush() }) + // A workspace only main held (a CLI-started tab) shows on the window's first visit, so the + // activation finds main's tab rather than seeding a second one. + const stopHeldWorkspacesListener = onWorkspaceStartsDrawing( + store, + () => Object.keys(cache).filter((key) => !(key in shown)), + () => streamOpen && scheduleFlush() + ) + return { start: () => { started = true @@ -314,20 +320,15 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa }, close: () => { live = false + stopHeldWorkspacesListener() stopHeldTabsListener() stopReleaseListener() stream.close() }, // Only a pane flush draws: the same workspaces it shows. - holdsTerminalPane: (worktreeId, { tabId, leafId, ptyId }) => + holdsTerminalPane: (worktreeId, pane) => framesDrawWorkspace(store.getState())(worktreeId) && - (cache[worktreeId]?.tabs.some( - (tab) => - tab.entityId === tabId && - (tab.terminal?.panes.some((pane) => pane.leafId === leafId && pane.ptyId === ptyId) ?? - false) - ) ?? - false), + layoutHoldsBoundPane(cache[worktreeId], pane), // A closed stream's cache is frozen: main may have closed the tab since. terminalTab: (worktreeId, tabId) => streamOpen diff --git a/src/renderer/src/store/terminals/terminal-tab-create-through-layout-owner.ts b/src/renderer/src/store/terminals/terminal-tab-create-through-layout-owner.ts index 9c5b3b71383..b47278f4405 100644 --- a/src/renderer/src/store/terminals/terminal-tab-create-through-layout-owner.ts +++ b/src/renderer/src/store/terminals/terminal-tab-create-through-layout-owner.ts @@ -181,5 +181,47 @@ export function createInitialTerminalThroughLayoutOwner( return true } +/** + * A live terminal the host's census finds no pane for, shown through main: main answers with the + * pane it already holds for it (a CLI tab this window never mounted), else makes a tab bound to it, + * and its frame draws either. 'as-today': the window shows it itself, since a paired server owns + * the workspace, main does not own it (a twin), or the call threw (a thrown write is rolled back). + * 'refused': main showed nothing, so the terminal stays without a pane. + */ +export async function adoptLiveTerminalThroughLayoutOwner( + getState: () => Parameters[0], + worktreeId: string, + terminal: { ptyId: string; recordedPaneKey?: string } +): Promise<'shown' | 'refused' | 'as-today'> { + if (runtimeEnvironmentIdUnlessTwin(getState(), worktreeId) !== null) { + return 'as-today' + } + try { + const reply = await callRuntimeRpc({ kind: 'local' }, 'layout.apply', { + command: { + type: 'createTerminalTab', + workspace: worktreeId, + tabId: createBrowserUuid(), + leafId: createBrowserUuid(), + adopt: terminal + } + }) + if (reply.ok) { + return 'shown' + } + if (reply.code === 'host_not_owned') { + return 'as-today' + } + console.warn('[layout-owner] live terminal adoption refused:', reply.code) + return 'refused' + } catch (error) { + console.warn( + '[layout-owner] live terminal adoption failed:', + error instanceof Error ? error.message : error + ) + return 'as-today' + } +} + /** The window's own mark on a first terminal: its spawn does not count as workspace activity. */ const ACTIVATION_ROW_FIELDS = { pendingActivationSpawn: true } as const diff --git a/src/shared/rpc-contract/workspace-layout-params.ts b/src/shared/rpc-contract/workspace-layout-params.ts index ad631c7fec8..e72485e7ab2 100644 --- a/src/shared/rpc-contract/workspace-layout-params.ts +++ b/src/shared/rpc-contract/workspace-layout-params.ts @@ -184,6 +184,10 @@ const CreateTerminalTabCommand = z.object({ .optional(), /** Only into a workspace with no tab in main's copy; otherwise nothing is created. */ initial: z.literal('activation').optional(), + /** A live terminal no pane shows: its pane when main holds one, else this tab, bound to it. */ + adopt: z + .object({ ptyId: z.string().min(1), recordedPaneKey: z.string().min(1).optional() }) + .optional(), split: NewGroupSplit }) diff --git a/src/shared/workspace-layout/workspace-layout-command-types.ts b/src/shared/workspace-layout/workspace-layout-command-types.ts index 7b93e2ed94e..8dead527eb9 100644 --- a/src/shared/workspace-layout/workspace-layout-command-types.ts +++ b/src/shared/workspace-layout/workspace-layout-command-types.ts @@ -4,6 +4,7 @@ // `startup_agent_conflict` (host support), `file_outside_workspace` (paths) and // `terminal_liveness_unavailable` (PTY host). +import type { LiveTerminalAdoption } from './workspace-layout-terminal-adoption' import type { SleepingAgentSessionRecord } from '../agent-session-resume' import type { AgentType } from '../agent-status-types' import type { AiVaultSessionTitle } from '../ai-vault-session-title' @@ -44,6 +45,8 @@ export type LayoutCommand = quickCommandLabel?: string creation?: Partial initial?: InitialSurfaceReason + /** A live terminal no pane shows: its pane when main holds one, else this tab, bound to it. */ + adopt?: LiveTerminalAdoption split?: NewGroupSplit }) | (On & { diff --git a/src/shared/workspace-layout/workspace-layout-tab-commands.ts b/src/shared/workspace-layout/workspace-layout-tab-commands.ts index 304f76474e6..79845c83f28 100644 --- a/src/shared/workspace-layout/workspace-layout-tab-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-tab-commands.ts @@ -31,6 +31,7 @@ import { type WorkspaceLayoutModel } from './workspace-layout-model' import { admitsInitialSurface } from './workspace-layout-initial-surface' +import { heldPaneOfTerminal } from './workspace-layout-terminal-adoption' import { removeTabFromWorkspace, withWorkspace } from './workspace-layout-removal' type RefusedClose = NonNullable[number] @@ -43,6 +44,11 @@ export function createTerminalTab( if (command.initial && !admitsInitialSurface(model, command.workspace, command.initial)) { return applied(model) } + const held = + command.adopt && heldPaneOfTerminal(model.workspaces[command.workspace], command.adopt) + if (held) { + return applied(model, held) + } const workspace = workspaceOrEmpty(model, command.workspace) const terminals = workspace.tabs.flatMap((tab) => (tab.kind === 'terminal' ? [tab] : [])) const ordinal = getNextTerminalOrdinal( @@ -64,6 +70,12 @@ export function createTerminalTab( } const placed = placeNewTab(workspace, command.workspace, tab, command, context) const paneKey = paneKeyOf(id, leafId) + if (command.adopt) { + // Its terminal already runs: the pane binds to it and nothing starts. + const leaves = { ...placed.leaves, [leafId]: { ptyId: command.adopt.ptyId } } + const adopted = withWorkspace(model, command.workspace, { ...placed, leaves }) + return applied(adopted, { tabId: id, leafId, paneKey }) + } return applied( withWorkspace(model, command.workspace, placed), { tabId: id, leafId, paneKey }, diff --git a/src/shared/workspace-layout/workspace-layout-terminal-adoption.test.ts b/src/shared/workspace-layout/workspace-layout-terminal-adoption.test.ts new file mode 100644 index 00000000000..0fda527ce3c --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-terminal-adoption.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { applyLayoutCommand } from './workspace-layout-commands' +import { asLoaded, build, testContext, WS } from './workspace-layout-command.test-fixture' +import type { WorkspaceLayoutModel } from './workspace-layout-model' +import { saveWorkspaceLayout } from './workspace-layout-save' + +const PTY = `${WS}@@cli00001` + +/** A tab main made for a CLI terminal: bound to it, or still unbound (its spawn not yet bound). */ +function withCliTab(bound: boolean) { + const context = testContext() + const { model, results } = build(context, [{ type: 'createTerminalTab', workspace: WS }]) + const { tabId, leafId, paneKey } = results[0]! + const workspace = model.workspaces[WS]! + const next: WorkspaceLayoutModel = bound + ? { + ...model, + workspaces: { [WS]: { ...workspace, leaves: { [leafId!]: { ptyId: PTY } } } } + } + : model + return { context, model: next, tabId: tabId!, leafId: leafId!, paneKey: paneKey! } +} + +const adopt = (extra: { recordedPaneKey?: string } = {}) => ({ + type: 'createTerminalTab' as const, + workspace: WS, + tabId: 'window-tab', + leafId: '00000000-0000-4000-8000-0000000000ff', + adopt: { ptyId: PTY, ...extra } +}) + +describe('a live terminal no pane shows, through main', () => { + it('names the pane main already shows it in, and makes no tab', () => { + const { context, model, tabId, leafId, paneKey } = withCliTab(true) + const applied = applyLayoutCommand(model, adopt(), context) + expect(applied).toMatchObject({ ok: true, result: { tabId, leafId, paneKey } }) + expect(applied.ok && applied.model.workspaces[WS]!.tabs).toHaveLength(1) + expect(applied.ok && applied.effects.startPaneKeys).toEqual([]) + }) + + it('names the pane the host recorded for it while main has not bound it yet', () => { + const { context, model, tabId, paneKey } = withCliTab(false) + const applied = applyLayoutCommand(model, adopt({ recordedPaneKey: paneKey }), context) + expect(applied).toMatchObject({ ok: true, result: { tabId, paneKey } }) + expect(applied.ok && applied.model.workspaces[WS]!.tabs).toHaveLength(1) + }) + + it('makes a new tab when the recorded pane now shows another terminal', () => { + const { context, model, paneKey, leafId } = withCliTab(false) + const rebound: WorkspaceLayoutModel = { + ...model, + workspaces: { + [WS]: { ...model.workspaces[WS]!, leaves: { [leafId]: { ptyId: `${WS}@@other0001` } } } + } + } + const applied = applyLayoutCommand(rebound, adopt({ recordedPaneKey: paneKey }), context) + if (!applied.ok) { + throw new Error(applied.code) + } + expect(applied.result).toMatchObject({ tabId: 'window-tab' }) + expect(applied.model.workspaces[WS]!.tabs).toHaveLength(2) + expect(applied.model.workspaces[WS]!.leaves?.['00000000-0000-4000-8000-0000000000ff']).toEqual({ + ptyId: PTY + }) + }) + + it('otherwise shows it in a new tab bound to it, starting nothing', () => { + const context = testContext() + const applied = applyLayoutCommand(build(context, []).model, adopt(), context) + if (!applied.ok) { + throw new Error(applied.code) + } + expect(applied.result).toMatchObject({ tabId: 'window-tab' }) + expect(applied.effects.startPaneKeys).toEqual([]) + expect(applied.model.workspaces[WS]!.leaves).toEqual({ + '00000000-0000-4000-8000-0000000000ff': { ptyId: PTY } + }) + const saved = saveWorkspaceLayout(asLoaded(applied.model)) + expect(saved.tabsByWorktree?.[WS]?.map((row) => row.ptyId)).toEqual([PTY]) + }) +}) diff --git a/src/shared/workspace-layout/workspace-layout-terminal-adoption.ts b/src/shared/workspace-layout/workspace-layout-terminal-adoption.ts new file mode 100644 index 00000000000..2db9db7d05f --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-terminal-adoption.ts @@ -0,0 +1,40 @@ +// A live terminal no pane shows (the host's census found it unowned), shown through main: in the +// pane main already holds for it, else in a new tab bound to it. Never a second tab for it. + +import { leafIdsOf, locatePane } from './workspace-layout-command-steps' +import type { LayoutCommandResult } from './workspace-layout-command-types' +import { paneKeyOf, type WorkspaceLayout } from './workspace-layout-model' + +export type LiveTerminalAdoption = { + ptyId: string + /** The pane the host last recorded for it: main holds it, still unbound, before the spawn binds it. */ + recordedPaneKey?: string +} + +/** The pane of `workspace` that already shows the terminal, or null. */ +export function heldPaneOfTerminal( + workspace: WorkspaceLayout | undefined, + { ptyId, recordedPaneKey }: LiveTerminalAdoption +): LayoutCommandResult | null { + if (!workspace) { + return null + } + for (const tab of workspace.tabs) { + if (tab.kind !== 'terminal') { + continue + } + const leafId = leafIdsOf(tab).find((leaf) => workspace.leaves?.[leaf]?.ptyId === ptyId) + if (leafId) { + return { tabId: tab.id, leafId, paneKey: paneKeyOf(tab.entityId, leafId) } + } + } + const recorded = recordedPaneKey ? locatePane(workspace, recordedPaneKey) : null + // A recorded pane bound since to another terminal shows that one, not this. + return recorded && !workspace.leaves?.[recorded.leafId]?.ptyId + ? { + tabId: recorded.tab.id, + leafId: recorded.leafId, + paneKey: paneKeyOf(recorded.tab.entityId, recorded.leafId) + } + : null +} diff --git a/tests/e2e/cli-first-worktree-single-terminal.spec.ts b/tests/e2e/cli-first-worktree-single-terminal.spec.ts new file mode 100644 index 00000000000..c4735e147af --- /dev/null +++ b/tests/e2e/cli-first-worktree-single-terminal.spec.ts @@ -0,0 +1,124 @@ +/** + * A terminal the CLI starts in a worktree the window never opened shows as that one tab on the + * first visit, and again after a relaunch: no empty workspace, no second "Terminal 1" beside it + * (layout-owner build only; main holds the tab before the window ever draws the workspace). + */ + +import { existsSync } from 'node:fs' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { switchToWorktree, waitForSessionReady } from './helpers/store' + +const LAYOUT_OWNER_BUILD = existsSync( + path.join(process.cwd(), 'out', 'main', 'ORCA_LAYOUT_OWNER_MAIN') +) + +type Tabs = { window: string[]; storedRows: string[]; storedTabBar: string[] } + +async function createWorktree(page: Page, name: string): Promise { + return page.evaluate(async (worktreeName) => { + const state = window.__store!.getState() + const repoId = state.allWorktrees()[0]?.repoId + if (!repoId) { + throw new Error('seed repo was not loaded') + } + return (await state.createWorktree(repoId, worktreeName, undefined, 'skip')).worktree.id + }, name) +} + +function createdTabId(reply: unknown): string | undefined { + const result = reply && typeof reply === 'object' && 'result' in reply ? reply.result : null + const terminal = + result && typeof result === 'object' && 'terminal' in result ? result.terminal : null + const tabId = + terminal && typeof terminal === 'object' && 'tabId' in terminal ? terminal.tabId : null + return typeof tabId === 'string' ? tabId : undefined +} + +/** The CLI's create: the runtime's `terminal.create`, in the background as a worker's would be. */ +async function createCliTerminal(page: Page, worktreeId: string): Promise { + const reply = await page.evaluate( + (id) => + window.api.runtime.call({ + method: 'terminal.create', + params: { + worktree: `id:${id}`, + command: 'sleep 600', + focus: false, + activate: false, + presentation: 'background' + } + }), + worktreeId + ) + const tabId = createdTabId(reply) + if (!tabId) { + throw new Error(`terminal.create gave no tab: ${JSON.stringify(reply).slice(0, 300)}`) + } + return tabId +} + +async function readTabs(page: Page, worktreeId: string): Promise { + return page.evaluate(async (id) => { + const session = await window.api.session.get() + return { + window: (window.__store!.getState().tabsByWorktree[id] ?? []).map((tab) => tab.id), + storedRows: (session.tabsByWorktree?.[id] ?? []).map((row) => row.id), + storedTabBar: (session.unifiedTabs?.[id] ?? []) + .filter((entry) => entry.contentType === 'terminal') + .map((entry) => entry.entityId) + } + }, worktreeId) +} + +/** Polls until the tabs read `expected` on three reads in a row: a late duplicate fails it. */ +async function expectSettledTabs(page: Page, worktreeId: string, expected: Tabs): Promise { + let streak = 0 + await expect + .poll( + async () => { + const tabs = await readTabs(page, worktreeId) + streak = JSON.stringify(tabs) === JSON.stringify(expected) ? streak + 1 : 0 + return streak >= 3 ? tabs : { ...tabs, streak } + }, + { timeout: 30_000, intervals: [500] } + ) + .toEqual(expected) +} + +test('a CLI-started terminal in a never-opened worktree shows as its one tab, before and after a relaunch', async ({ + testRepoPath +}, testInfo) => { + test.skip(!LAYOUT_OWNER_BUILD, 'layout-owner build only') + const session = createRestartSession(testInfo) + let app: Awaited>['app'] | undefined + try { + const first = await session.launch() + app = first.app + await waitForSessionReady(first.page) + await attachRepoAndOpenTerminal(first.page, testRepoPath) + const worktreeId = await createWorktree(first.page, `cli-first-${Date.now()}`) + const cliTabId = await createCliTerminal(first.page, worktreeId) + const one: Tabs = { window: [cliTabId], storedRows: [cliTabId], storedTabBar: [cliTabId] } + // Main holds it before the window has ever drawn the workspace. + await expect + .poll(async () => (await readTabs(first.page, worktreeId)).storedRows, { timeout: 15_000 }) + .toEqual([cliTabId]) + expect((await readTabs(first.page, worktreeId)).window).toEqual([]) + + await switchToWorktree(first.page, worktreeId) + await expectSettledTabs(first.page, worktreeId, one) + await session.close(app) + app = undefined + + const second = await session.launch() + app = second.app + await waitForSessionReady(second.page) + await switchToWorktree(second.page, worktreeId) + await expectSettledTabs(second.page, worktreeId, one) + } finally { + await session.dispose(app) + } +})