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
This commit is contained in:
Jinwoo Hong
2026-10-18 16:25:49 -04:00
committed by GitHub
parent b0a7d2eb48
commit eaa1ee131d
15 changed files with 682 additions and 32 deletions
+1 -1
View File
@@ -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
@@ -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()
}
@@ -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()
@@ -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<void>((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<ReturnType<typeof bootWindow>>) =>
(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()
})
})
@@ -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<typeof useAppStore.getState>,
| 'createTab'
| 'ptyIdsByTabId'
| 'setTabLayout'
| 'tabsByWorktree'
| 'terminalLayoutsByTabId'
| 'updateTabPtyId'
| 'replaceTerminalLayoutPanePtyId'
>
export type LiveSurfaceAdoptionStore = WorktreeOperationRouteState &
Pick<
ReturnType<typeof useAppStore.getState>,
| '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
@@ -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
)
}
@@ -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() }])
@@ -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<AppState>) => 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
@@ -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<typeof runtimeEnvironmentIdUnlessTwin>[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<LayoutApplyReply>({ 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
@@ -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
})
@@ -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<LayoutTerminalCreation>
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 & {
@@ -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<LayoutCommandResult['refused']>[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 },
@@ -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])
})
})
@@ -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
}
@@ -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<string> {
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<string> {
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<Tabs> {
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<void> {
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<ReturnType<typeof session.launch>>['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)
}
})