Save runtime-started tabs in the tab bar before their terminal starts (#27083)

* Write runtime-started panes with their tab bar entry and group before they spawn

Co-Authored-By: Claude <noreply@anthropic.com>

* List the headless reorder's row-order finding and mark the headless group writer for the switch

Co-Authored-By: Claude <noreply@anthropic.com>

* Admit runtime-started tabs into the targeted or focused tab group

A phone, CLI or orchestration create on a headless host with two tab groups was written into the
first group, so after a restart the tab moved groups. The phone's targetGroupId, else the host's
focused group, now reaches the pane admission.

Co-Authored-By: Claude <noreply@anthropic.com>

* Admit an untargeted runtime-started tab into the first tab group

Defaulting to the host's focused group moved a CLI-created tab to the right group after a restart
and in the desktop window, while main and the live view keep it in the first group. Only the
phone's explicit targetGroupId now picks the group.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jinwoo Hong
2026-10-10 21:22:45 -04:00
committed by GitHub
co-authored by Claude
parent be787967f7
commit f6d536dcda
49 changed files with 768 additions and 292 deletions
@@ -159,7 +159,7 @@ describe('registerPtyHandlers', () => {
leafId,
ptyId: expect.any(String),
incarnationId: expect.any(String),
hostAdmittedMembership: true,
mayCreate: false,
origin: 'spawn'
})
})
@@ -519,7 +519,7 @@ describe('registerPtyHandlers', () => {
leafId,
ptyId: 'pty-shared',
startupCwd: '/tmp',
hostAdmittedMembership: true,
mayCreate: false,
origin: 'spawn'
})
})
@@ -542,7 +542,7 @@ describe('registerPtyHandlers', () => {
tabId: 'tab-remote',
leafId,
ptyId: 'ssh:ssh-1@@relay-pty',
hostAdmittedMembership: true,
mayCreate: false,
origin: 'spawn'
},
'ssh:ssh-1'
@@ -202,7 +202,7 @@ describe('registerPtyHandlers', () => {
tabId: 'tab-remote',
leafId,
ptyId: 'ssh:ssh-reattach-ok@@relay-pty',
hostAdmittedMembership: true,
mayCreate: false,
origin: 'reattach'
},
'ssh:ssh-reattach-ok'
@@ -170,7 +170,7 @@ describe('pty spawn placement threading', () => {
leafId: LEAF,
ptyId: expect.any(String),
incarnationId: expect.any(String),
hostAdmittedMembership: true,
mayCreate: false,
placement: SPLIT,
origin: 'spawn'
})
+2 -1
View File
@@ -150,7 +150,8 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) {
tabId,
leafId,
ptyId: ctx.result.id,
hostAdmittedMembership: true,
// The runtime wrote the pane before spawning; a pane gone by now was closed meanwhile.
mayCreate: false,
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
...(ctx.cwd ? { startupCwd: ctx.cwd } : {}),
...(expectedSourceBinding ? { expectedSourceBinding } : {}),
@@ -47,7 +47,23 @@ function persistedTabIds(session: WorkspaceSessionState, worktreeId: string): st
return (session.tabsByWorktree?.[worktreeId] ?? []).map((tab) => tab.id)
}
describe('host-admitted terminal membership survives a stale renderer replay', () => {
/** `orca terminal create`: the runtime writes the pane, then binds the terminal it started. */
async function startRuntimePane(
store: Awaited<ReturnType<typeof createStore>>,
pane: { worktreeId: string; tabId: string; leafId: string; ptyId: string; incarnationId?: string }
): Promise<boolean> {
expect(
await store.admitTerminalPane({
type: 'createTerminalTab',
workspace: pane.worktreeId,
tabId: pane.tabId,
leafId: pane.leafId
})
).toBe('admitted')
return store.persistPtyBinding({ ...pane, mayCreate: false })
}
describe('runtime-admitted terminal membership survives a stale renderer replay', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-host-membership-'))
})
@@ -57,21 +73,26 @@ describe('host-admitted terminal membership survives a stale renderer replay', (
rmSync(testState.dir, { recursive: true, force: true })
})
it('keeps the first host-admitted tab when the renderer replays its pre-create tab list', async () => {
it('keeps the first runtime-admitted tab when the renderer replays its pre-create tab list', async () => {
const store = await createStore()
store.setWorkspaceSession(rendererSession())
// `orca terminal create`: the host mints a tab the renderer has never seen.
expect(
await store.persistPtyBinding({
await startRuntimePane(store, {
worktreeId: WORKTREE,
tabId: 'host-tab',
leafId: TEST_LEAF_2,
ptyId: 'host-pty',
hostAdmittedMembership: true
ptyId: 'host-pty'
})
).toBe(true)
expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toContain('host-tab')
const session = store.getWorkspaceSession()
expect(persistedTabIds(session, WORKTREE)).toContain('host-tab')
// Written with its tab-bar entry and group, so no view reads a row the tab bar lacks.
expect(session.unifiedTabs?.[WORKTREE]?.map((tab) => tab.entityId)).toContain('host-tab')
expect(session.tabGroups?.[WORKTREE]?.flatMap((group) => group.tabOrder)).toContain('host-tab')
expect(session.terminalLayoutsByTabId['host-tab']?.ptyIdsByLeafId).toEqual({
[TEST_LEAF_2]: 'host-pty'
})
// The renderer's debounced writer flushes a snapshot taken before the create.
store.setWorkspaceSession(rendererSession())
@@ -79,16 +100,15 @@ describe('host-admitted terminal membership survives a stale renderer replay', (
expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toContain('host-tab')
})
it('keeps a host-admitted tab in a second worktree of the same repo', async () => {
it('keeps a runtime-admitted tab in a second worktree of the same repo', async () => {
const store = await createStore()
store.setWorkspaceSession(rendererSession())
await store.persistPtyBinding({
await startRuntimePane(store, {
worktreeId: OTHER_WORKTREE,
tabId: 'host-tab-other',
leafId: TEST_LEAF_2,
ptyId: 'host-pty-other',
hostAdmittedMembership: true
ptyId: 'host-pty-other'
})
store.setWorkspaceSession(rendererSession())
@@ -100,12 +120,11 @@ describe('host-admitted terminal membership survives a stale renderer replay', (
store.setWorkspaceSession(rendererSession())
expect(
await store.persistPtyBinding({
await startRuntimePane(store, {
worktreeId: WORKTREE,
tabId: 'host-tab',
leafId: TEST_LEAF_2,
ptyId: 'host-pty',
hostAdmittedMembership: true
ptyId: 'host-pty'
})
).toBe(true)
expect(store.getWorkspaceSession().defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE]).toBe(
@@ -118,9 +137,8 @@ describe('host-admitted terminal membership survives a stale renderer replay', (
)
})
// Polarity: without the flag the renderer still owns membership, so a renderer
// spawn racing its own writer must not freeze the tab list.
it('leaves renderer-owned membership alone when the binding is not host-admitted', async () => {
// Polarity: a renderer spawn racing its own writer must not freeze the tab list.
it('leaves renderer-owned membership alone when the runtime did not admit the pane', async () => {
const store = await createStore()
store.setWorkspaceSession(rendererSession())
@@ -138,16 +156,15 @@ describe('host-admitted terminal membership survives a stale renderer replay', (
// Closing must still work afterwards. Closes are host-driven: the retirement is
// computed from the store's own session (see stageTerminalSurfaceRetirements),
// which is what outranks the fence this create just raised.
it('still lets the authoritative retirement path close the host-admitted tab', async () => {
it('still lets the authoritative retirement path close the runtime-admitted tab', async () => {
const store = await createStore()
store.setWorkspaceSession(rendererSession())
await store.persistPtyBinding({
await startRuntimePane(store, {
worktreeId: WORKTREE,
tabId: 'host-tab',
leafId: TEST_LEAF_2,
ptyId: 'host-pty',
incarnationId: 'host-incarnation',
hostAdmittedMembership: true
incarnationId: 'host-incarnation'
})
store.setWorkspaceSession(
@@ -164,4 +181,76 @@ describe('host-admitted terminal membership survives a stale renderer replay', (
expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual(['renderer-tab'])
})
it('refuses to bind a pane closed while its terminal started, and mints no tab for it', async () => {
const store = await createStore()
store.setWorkspaceSession(rendererSession())
expect(
await store.admitTerminalPane({
type: 'createTerminalTab',
workspace: WORKTREE,
tabId: 'host-tab',
leafId: TEST_LEAF_2
})
).toBe('admitted')
await store.withdrawTerminalPane({
type: 'closePane',
workspace: WORKTREE,
tabId: 'host-tab',
leafId: TEST_LEAF_2
})
expect(
await store.persistPtyBinding({
worktreeId: WORKTREE,
tabId: 'host-tab',
leafId: TEST_LEAF_2,
ptyId: 'host-pty',
mayCreate: false
})
).toBe(false)
expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual(['renderer-tab'])
})
it('refuses to bind a split pane that moved to another tab while its terminal started', async () => {
const store = await createStore()
store.setWorkspaceSession(rendererSession())
expect(
await store.admitTerminalPane({
type: 'splitPane',
workspace: WORKTREE,
tabId: 'renderer-tab',
leafId: TEST_LEAF_1,
direction: 'vertical',
newLeafId: TEST_LEAF_2
})
).toBe('admitted')
await expect(
store.moveTerminalLeafToNewTab({
worktreeId: WORKTREE,
sourceTabId: 'renderer-tab',
targetTabId: 'moved-tab',
leafId: TEST_LEAF_2,
ptyId: null
})
).resolves.toMatchObject({ status: 'moved' })
expect(
await store.persistPtyBinding({
worktreeId: WORKTREE,
tabId: 'renderer-tab',
leafId: TEST_LEAF_2,
ptyId: 'late-pty',
mayCreate: false
})
).toBe(false)
const session = store.getWorkspaceSession()
expect(session.terminalLayoutsByTabId['renderer-tab']?.root).toEqual({
type: 'leaf',
leafId: TEST_LEAF_1
})
expect(
session.terminalLayoutsByTabId['moved-tab']?.ptyIdsByLeafId?.[TEST_LEAF_2]
).toBeUndefined()
})
})
@@ -11,6 +11,7 @@ import type {
ProfileStateDomainReplacement
} from './profile-state-authority'
import { Store } from './store'
import { toSshExecutionHostId } from '../../../shared/execution-host'
export function deferred<T>() {
let resolve!: (value: T) => void
@@ -147,3 +148,20 @@ export async function fixture(legacyOpenCodeGoApiKey?: string) {
}
return { store, authority, readState }
}
/** The runtime writes a pane before it spawns; a runtime spawn commit binds only that pane. */
export async function admitRuntimeSpawnPane(
store: Store,
pane: { worktreeId: string; tabId: string; leafId: string },
connectionId?: string | null
): Promise<void> {
await store.admitTerminalPane(
{
type: 'createTerminalTab',
workspace: pane.worktreeId,
tabId: pane.tabId,
leafId: pane.leafId
},
connectionId ? toSshExecutionHostId(connectionId) : undefined
)
}
@@ -22,7 +22,20 @@ import type {
TerminalLeafMoveRequest,
TerminalLeafMoveResult
} from '../../../shared/terminal-leaf-move'
import { moveLeaf } from '../terminal-topology/terminal-topology-commit'
import {
admitPane,
moveLeaf,
withdrawPane,
type TerminalTopologyCommitContext
} from '../terminal-topology/terminal-topology-commit'
import type {
TerminalPaneAdmission,
TerminalPaneAdmissionOutcome
} from '../terminal-topology/terminal-pane-admission'
import type {
CommandOf,
LayoutRefusalCode
} from '../../../shared/workspace-layout/workspace-layout-command-types'
import { findTerminalBindingConflict } from '../../../shared/workspace-layout/terminal-owner-invariants'
type PtyBindingPersistenceOperationsRuntime = Pick<
@@ -46,8 +59,6 @@ export type PersistPtyBindingArgs = {
startupCwd?: string
expectedBinding?: { ptyId: string; incarnationId?: string }
expectedSourceBinding?: PtyBindingSourceExpectation
/** Set by host-initiated creates, which have no renderer session writer behind them. */
hostAdmittedMembership?: boolean
/**
* Defaults true, which is what `pty:spawn` needs — it can beat the debounced layout writer
* and must be able to mint the surface it is binding. A reattach is the opposite: the pane
@@ -217,16 +228,41 @@ export class PtyBindingPersistenceOperations {
* the binding domain only for its runtime and partition access; the commit module owns the write.
*/
moveTerminalLeafToNewTab(request: TerminalLeafMoveRequest): Promise<TerminalLeafMoveResult> {
const { runtime, sessions } = this[ptyBindingPersistenceOperationsContext]
return runtime.runDurableMutation(
moveLeaf(request, {
state: runtime.state,
hostIds: () => sessions.getWorkspaceSessionHostIds(),
getSession: (hostId) => sessions.getWorkspaceSession(hostId),
markDirty: (domain) => runtime.dirtyProfileStateDomains?.add(domain)
})
return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation(
moveLeaf(request, topologyCommitContext(this))
)
}
/** Writes a runtime-started pane before its terminal starts; the commit module owns the write. */
admitTerminalPane(
admission: TerminalPaneAdmission,
hostId?: string | null
): Promise<TerminalPaneAdmissionOutcome> {
return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation(
admitPane(resolveHostId(hostId), admission, topologyCommitContext(this))
)
}
withdrawTerminalPane(
pane: CommandOf<'closePane'>,
hostId?: string | null
): Promise<LayoutRefusalCode | null> {
return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation(
withdrawPane(resolveHostId(hostId), pane, topologyCommitContext(this))
)
}
}
function topologyCommitContext(
owner: PtyBindingPersistenceOperations
): TerminalTopologyCommitContext {
const { runtime, sessions } = owner[ptyBindingPersistenceOperationsContext]
return {
state: runtime.state,
hostIds: () => sessions.getWorkspaceSessionHostIds(),
getSession: (hostId) => sessions.getWorkspaceSession(hostId),
markDirty: (domain) => runtime.dirtyProfileStateDomains?.add(domain)
}
}
function writePtyBinding(
@@ -64,8 +64,7 @@ const SCENARIOS: Scenario[] = [
leafId: TEST_LEAF_1,
ptyId: 'pty-new',
incarnationId: 'inc-new',
startupCwd: '/fixture/local/sub',
hostAdmittedMembership: true
startupCwd: '/fixture/local/sub'
},
placements: (leaf) => [
[NEW_TAB, 'agrees'],
@@ -110,8 +109,7 @@ const SCENARIOS: Scenario[] = [
worktreeId: FOLDER_WORKTREE,
tabId: 'tab-folder',
leafId: TEST_LEAF_1,
ptyId: 'pty-folder',
hostAdmittedMembership: true
ptyId: 'pty-folder'
},
placements: () => [
[NEW_TAB, 'agrees'],
@@ -18,15 +18,12 @@ export function applyPtyBinding(
const reconciledIncarnation =
args.expectedBinding !== undefined && args.incarnationId !== args.expectedBinding.incarnationId
let terminalMembershipChanged = false
let hostAdmittedTabCreated = false
const advanceTopologyFence = (): void => {
const repoId = getRepoIdFromWorktreeId(bindingWorktreeId)
const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0
// Why: a split, or a host-admitted tab the renderer has never seen, is itself
// the authority — with no fence the renderer's pre-create tab list replays
// over it and the tab is lost even on the repo's first such change.
const establishesMembershipAuthority =
args.expectedSourceBinding !== undefined || hostAdmittedTabCreated
// Why: a split is itself the authority — with no fence the renderer's pre-split
// pane list replays over it and the pane is lost even on the repo's first split.
const establishesMembershipAuthority = args.expectedSourceBinding !== undefined
if (
!reconciledIncarnation &&
(!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority))
@@ -62,7 +59,6 @@ export function applyPtyBinding(
)
} else {
terminalMembershipChanged = true
hostAdmittedTabCreated = args.hostAdmittedMembership === true
// Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned.
const nextTabs = [
...(tabs ?? []),
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fixture } from './profile-state-delayed-authority-fixture'
import { fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture'
import { OrcaRuntimeService } from '../../runtime/orca-runtime'
import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit'
import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state'
@@ -71,6 +71,7 @@ describe.each(['ipc', 'runtime'])('%s failed reattach routing', (controller) =>
})
ctx.result = result
ctx.metadataLeafId = binding.leafId
await admitRuntimeSpawnPane(store, binding, connectionId)
ctx.hostSessionBinding = { store, ...binding }
shutdown = vi.spyOn(ctx.provider, 'shutdown')
commit = () => commitRuntimePtySpawn(ctx)
@@ -1,5 +1,5 @@
import { expect, it, vi } from 'vitest'
import { fixture } from './profile-state-delayed-authority-fixture'
import { fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture'
import { OrcaRuntimeService } from '../../runtime/orca-runtime'
import { commitRuntimePtySpawn } from '../../ipc/pty/runtime/spawn-commit'
import { createRuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state'
@@ -78,6 +78,7 @@ it.each(
const ctx = createRuntimePtySpawnState(deps, { ...binding, connectionId, cols: 80, rows: 24 })
ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId }
ctx.stablePaneOwner = owner
await admitRuntimeSpawnPane(store, binding, connectionId)
ctx.hostSessionBinding = { store, ...binding }
ctx.metadataLeafId = binding.leafId
commit = () => commitRuntimePtySpawn(ctx)
@@ -1,5 +1,5 @@
import { expect, it, vi } from 'vitest'
import { fixture } from './profile-state-delayed-authority-fixture'
import { fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture'
import { OrcaRuntimeService } from '../../runtime/orca-runtime'
import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit'
import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state'
@@ -62,6 +62,7 @@ it.each(['ipc', 'runtime'])(
})
ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId }
ctx.metadataLeafId = binding.leafId
await admitRuntimeSpawnPane(store, binding)
ctx.hostSessionBinding = { store, ...binding }
commit = () => commitRuntimePtySpawn(ctx)
}
@@ -1,6 +1,6 @@
import { isTerminalSessionStorageCapacityFailure } from '../../../shared/terminal-session-state-save-failure'
import { afterEach, expect, it, vi } from 'vitest'
import { deferred, fixture } from './profile-state-delayed-authority-fixture'
import { deferred, fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture'
import { OrcaRuntimeService } from '../../runtime/orca-runtime'
import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit'
import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state'
@@ -72,6 +72,7 @@ it.each(
const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 })
ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId }
ctx.metadataLeafId = binding.leafId
await admitRuntimeSpawnPane(store, binding)
ctx.hostSessionBinding = { store, ...binding }
shutdown = vi.spyOn(ctx.provider, 'shutdown').mockImplementation(holdShutdown)
commit = () => commitRuntimePtySpawn(ctx)
@@ -137,6 +138,7 @@ it.each(['ipc', 'runtime'])(
const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 })
ctx.result = { id: binding.ptyId, incarnationId: binding.incarnationId }
ctx.metadataLeafId = binding.leafId
await admitRuntimeSpawnPane(store, binding)
ctx.hostSessionBinding = { store, ...binding }
commit = () => commitRuntimePtySpawn(ctx)
}
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { fixture } from './profile-state-delayed-authority-fixture'
import { fixture, admitRuntimeSpawnPane } from './profile-state-delayed-authority-fixture'
import { OrcaRuntimeService } from '../../runtime/orca-runtime'
import { commitPtyIpcSpawn } from '../../ipc/pty/ipc/spawn-commit'
import { createPtyIpcSpawnState } from '../../ipc/pty/ipc/spawn-state'
@@ -56,6 +56,7 @@ describe.each(['ipc', 'runtime'])('%s restored scrollback', (controller) => {
const ctx = createRuntimePtySpawnState(deps, { ...binding, cols: 80, rows: 24 })
ctx.result = result
ctx.metadataLeafId = binding.leafId
await admitRuntimeSpawnPane(store, binding)
ctx.hostSessionBinding = { store, ...binding }
commit = () => commitRuntimePtySpawn(ctx)
}
@@ -38,8 +38,7 @@ export function createMinimalPersistedTerminalTab(args: {
color: null,
sortOrder: args.existingTabCount,
createdAt: Date.now(),
...(args.startupCwd ? { startupCwd: args.startupCwd } : {}),
pendingActivationSpawn: true
...(args.startupCwd ? { startupCwd: args.startupCwd } : {})
}
}
@@ -68,7 +68,7 @@ function moveLeafInPartition(
const boundHere = sourceLayout.ptyIdsByLeafId?.[leafId]
const remainingLayout = retireLeavesFromTerminalLayout(sourceLayout, new Set([leafId]))
const remainingPtyIds = Object.values(remainingLayout?.ptyIdsByLeafId ?? {})
const { pendingActivationSpawn, ...row } = createMinimalPersistedTerminalTab({
const row = createMinimalPersistedTerminalTab({
worktreeId,
tabId: targetTabId,
ptyId: ptyId ?? '',
@@ -78,8 +78,6 @@ function moveLeafInPartition(
const targetTab = {
...row,
ptyId,
// A moved live pane reattaches; only an unbound one still spawns on activation.
...(ptyId ? {} : { pendingActivationSpawn }),
...(sourceTab.shellOverride ? { shellOverride: sourceTab.shellOverride } : {})
}
const nextTabs = tabs.map((tab) =>
@@ -0,0 +1,66 @@
// A runtime-started pane, written through the layout module before its terminal starts (design
// 4.1): Loader, then the command, then the Serializer, so the partition is rewritten from one model.
import { randomUUID } from 'node:crypto'
import type { ExecutionHostId } from '../../../shared/execution-host'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { locatePane } from '../../../shared/workspace-layout/workspace-layout-command-steps'
import type {
CommandOf,
LayoutCommand,
LayoutRefusalCode
} from '../../../shared/workspace-layout/workspace-layout-command-types'
import { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands'
import { loadWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-load'
import type { LoadedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-load-types'
import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids'
import { paneKeyOf } from '../../../shared/workspace-layout/workspace-layout-model'
import { saveWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-save'
/** A new tab, or a split of an existing pane, with the ids its terminal will run under. */
export type TerminalPaneAdmission =
| (CommandOf<'createTerminalTab'> & { tabId: string; leafId: string })
| (CommandOf<'splitPane'> & { newLeafId: string })
/** 'exists': the start attaches to a pane already in the layout, so nothing is written. */
export type TerminalPaneAdmissionOutcome = 'admitted' | 'exists' | LayoutRefusalCode
/** The partition to write, or null when nothing changes. */
export type PaneLayoutChange<T> = { value: T; session: WorkspaceSessionState | null }
export function admitPaneToSession(
hostId: ExecutionHostId,
session: WorkspaceSessionState,
admission: TerminalPaneAdmission
): PaneLayoutChange<TerminalPaneAdmissionOutcome> {
const leafId = admission.type === 'createTerminalTab' ? admission.leafId : admission.newLeafId
const loaded = loadWorkspaceLayout(hostId, session, nameBasedLoadContext())
const workspace = loaded.layout.workspaces[admission.workspace]
if (workspace && locatePane(workspace, paneKeyOf(admission.tabId, leafId))) {
return { value: 'exists', session: null }
}
return applyToSession(loaded, admission, 'admitted')
}
export function withdrawPaneFromSession(
hostId: ExecutionHostId,
session: WorkspaceSessionState,
pane: CommandOf<'closePane'>
): PaneLayoutChange<LayoutRefusalCode | null> {
return applyToSession(loadWorkspaceLayout(hostId, session, nameBasedLoadContext()), pane, null)
}
function applyToSession<T>(
loaded: LoadedWorkspaceLayout,
command: LayoutCommand,
committed: T
): PaneLayoutChange<T | LayoutRefusalCode> {
const applied = applyLayoutCommand(loaded.layout, command, {
mintId: randomUUID,
mintLeafId: randomUUID,
now: Date.now
})
return applied.ok
? { value: committed, session: saveWorkspaceLayout({ ...loaded, layout: applied.model }) }
: { value: applied.code, session: null }
}
@@ -100,14 +100,24 @@ describe('an old client pane tree that predates a split or close', () => {
it('keeps a pane another client split since the tree was built', async () => {
const store = await openWithPanes([TEST_LEAF_1])
// `terminal.split` from the CLI: a host-created pane bound against its source pane.
// `terminal.split` from the CLI: the runtime writes the pane, then binds it against its source.
await expect(
store.admitTerminalPane({
type: 'splitPane',
workspace: WT,
tabId: 'tab-a',
leafId: TEST_LEAF_1,
direction: 'vertical',
newLeafId: NEW_LEAF
})
).resolves.toBe('admitted')
await expect(
store.persistPtyBinding({
worktreeId: WT,
tabId: 'tab-a',
leafId: NEW_LEAF,
ptyId: 'pty-new',
hostAdmittedMembership: true,
mayCreate: false,
expectedSourceBinding: { tabId: 'tab-a', leafId: TEST_LEAF_1, ptyId: 'pty-1' }
})
).resolves.toBe(true)
@@ -5,6 +5,17 @@ import type {
TerminalLeafMoveResult
} from '../../../shared/terminal-leaf-move'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import type {
CommandOf,
LayoutRefusalCode
} from '../../../shared/workspace-layout/workspace-layout-command-types'
import {
admitPaneToSession,
withdrawPaneFromSession,
type PaneLayoutChange,
type TerminalPaneAdmission,
type TerminalPaneAdmissionOutcome
} from './terminal-pane-admission'
import { startSpan } from '../../observability/tracer'
import {
terminalSurfaceCloseMutation,
@@ -18,7 +29,12 @@ import { assignWorkspaceSessionPartition } from './terminal-topology-membership'
// the pane move; the close transform still lives in runtime/ and other writers move here later.
/** Bindings are not listed: `persistPtyBinding` already records `persistence.pty-binding`. */
type TerminalTopologyCommitKind = 'close_leaf' | 'close_tab' | 'move_leaf'
type TerminalTopologyCommitKind =
| 'close_leaf'
| 'close_tab'
| 'move_leaf'
| 'admit_pane'
| 'withdraw_pane'
export function closeLeafOrTab(
commit: TerminalSurfaceCloseCommit
@@ -43,6 +59,45 @@ export function moveLeaf(
)
}
/**
* Writes the pane, its tab-bar entry and group before its terminal starts (design 4.1), through the
* layout module.
*/
export function admitPane(
hostId: ExecutionHostId,
admission: TerminalPaneAdmission,
context: TerminalTopologyCommitContext
): () => DurableProfileStateMutation<TerminalPaneAdmissionOutcome> {
return traced(
'admit_pane',
() =>
commitPaneChange(
hostId,
admitPaneToSession(hostId, context.getSession(hostId), admission),
context
),
(outcome) => (outcome === 'admitted' || outcome === 'exists' ? undefined : outcome)
)
}
/** Removes a pane `admitPane` wrote whose start then failed, so a failed create leaves no tab. */
export function withdrawPane(
hostId: ExecutionHostId,
pane: CommandOf<'closePane'>,
context: TerminalTopologyCommitContext
): () => DurableProfileStateMutation<LayoutRefusalCode | null> {
return traced(
'withdraw_pane',
() =>
commitPaneChange(
hostId,
withdrawPaneFromSession(hostId, context.getSession(hostId), pane),
context
),
(code) => code ?? undefined
)
}
/**
* One `persistence.terminal-topology` span per commit, from admission to the in-memory write.
* Attributes stay low-cardinality: no pane key, PTY id or path; `refusalOf` returns a fixed code.
@@ -82,7 +137,7 @@ type TopologyState = Pick<
'workspaceSession' | 'workspaceSessionsByHostId' | 'ui' | 'sshRemotePtyLeases'
>
type TerminalTopologyCommitContext = {
export type TerminalTopologyCommitContext = {
state: TopologyState
hostIds: () => ExecutionHostId[]
getSession: (hostId: ExecutionHostId) => WorkspaceSessionState
@@ -147,3 +202,19 @@ function commitLeafMove(
rollback: () => restores.forEach((restore) => restore())
}
}
function commitPaneChange<T>(
hostId: ExecutionHostId,
change: PaneLayoutChange<T>,
context: TerminalTopologyCommitContext
): DurableProfileStateMutation<T> {
if (!change.session) {
return { value: change.value, persist: false }
}
const restore = writeRestorable(
() => context.getSession(hostId),
(value) => context.markDirty(assignWorkspaceSessionPartition(context.state, hostId, value)),
change.session
)
return { value: change.value, rollback: restore }
}
@@ -553,24 +553,33 @@ async function runSeed(seed: number): Promise<string[]> {
const ptyId = ids.pty(worktreeId)
const ticks = Math.floor(random() * 4)
log.push(`${op} ${target.tabId}:${target.leafId} → ${leafId} ${ptyId} stale-save+${ticks}`)
// `terminal.split` from a client or the CLI: main mints the pane while the window's
// in-flight save lacks it.
// `terminal.split` from a client or the CLI: main writes the pane, then binds it, while
// the window's in-flight save lacks it.
writer = 'host split'
const [bound] = await Promise.all([
store.persistPtyBinding(() => {
writer = 'host split'
return {
worktreeId,
store
.admitTerminalPane({
type: 'splitPane',
workspace: worktreeId,
tabId: target.tabId,
leafId,
ptyId,
hostAdmittedMembership: true,
expectedSourceBinding: {
leafId: target.leafId,
direction: 'vertical',
newLeafId: leafId
})
.then(() =>
store.persistPtyBinding({
worktreeId,
tabId: target.tabId,
leafId: target.leafId,
ptyId: target.pane.ptyId!
}
}
}),
leafId,
ptyId,
mayCreate: false,
expectedSourceBinding: {
tabId: target.tabId,
leafId: target.leafId,
ptyId: target.pane.ptyId!
}
})
),
staleSave(window.snapshot(), ticks)
])
expect(bound).toBe(true)
@@ -24,7 +24,7 @@ export function buildHeadlessTerminalSplitLayout(
direction: 'horizontal' | 'vertical'
}
): TerminalLayoutSnapshot {
// Why: PTY admission durably appends a fallback vertical leaf before this exact-direction commit.
// Why: the stored tree may already hold the new leaf; it is placed again beside its source.
const currentRoot = existing?.root ? removeLayoutLeaf(existing.root, args.leafId) : null
const existingRoot: TerminalPaneLayoutNode = currentRoot ?? {
type: 'leaf',
@@ -18,7 +18,6 @@ import {
buildHeadlessMobileSessionTabGroups,
cloneTerminalLayoutSnapshot
} from './mobile-session-layout-projection'
import { buildHeadlessTerminalSplitLayout } from './headless-terminal-split-layout'
export class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh {
protected applyMobileSessionTabNavigation(
@@ -171,38 +170,6 @@ export class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeW
this.emitMobileSessionTabsSnapshot(nextSnapshot)
}
// Why: a headless split only updated the LIVE session snapshot, never the
// persisted workspace session layout. So a later snapshot rebuild (e.g. on the
// next terminal create) re-derived from the stale single-leaf persisted layout
// and collapsed the split. Persist the new split leaf into the workspace
// session's terminalLayoutsByTabId so the split survives rebuilds.
protected persistHeadlessTerminalSplit(args: {
worktreeId: string
tabId: string
leafId: string
ptyId: string
splitFromLeafId: string
direction: 'horizontal' | 'vertical'
}): boolean {
const session = this.getWorkspaceSessionForWorktree(args.worktreeId)
if (!session || !this.store?.setWorkspaceSession) {
return false
}
const existing = session.terminalLayoutsByTabId?.[args.tabId]
const nextLayout = buildHeadlessTerminalSplitLayout(
existing ? cloneTerminalLayoutSnapshot(existing) : undefined,
args
)
this.setWorkspaceSessionForWorktree(args.worktreeId, {
...session,
terminalLayoutsByTabId: {
...session.terminalLayoutsByTabId,
[args.tabId]: nextLayout
}
})
return true
}
protected persistHeadlessTerminalActiveLeaf(
worktreeId: string,
tab: RuntimeMobileSessionTerminalTab
@@ -259,29 +259,6 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu
}
}
protected persistHeadlessTerminalTabOrder(worktreeId: string, tabOrder: readonly string[]): void {
const session = this.getWorkspaceSessionForWorktree(worktreeId)
if (!session || !this.store?.setWorkspaceSession) {
return
}
const orderIndexByTabId = new Map(tabOrder.map((tabId, index) => [tabId, index]))
const tabs = session.tabsByWorktree[worktreeId] ?? []
const reordered = [...tabs]
.sort((a, b) => {
const aIndex = orderIndexByTabId.get(a.id) ?? Number.MAX_SAFE_INTEGER
const bIndex = orderIndexByTabId.get(b.id) ?? Number.MAX_SAFE_INTEGER
return aIndex - bIndex || a.sortOrder - b.sortOrder || a.createdAt - b.createdAt
})
.map((tab, index) => ({ ...tab, sortOrder: index }))
this.setWorkspaceSessionForWorktree(worktreeId, {
...session,
tabsByWorktree: {
...session.tabsByWorktree,
[worktreeId]: reordered
}
})
}
protected emitMobileSessionTabsSnapshot(snapshot: RuntimeMobileSessionTabsSnapshot): void {
if (this.mobileSessionTabListeners.size === 0) {
return
@@ -111,6 +111,7 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca
...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}),
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
...(opts.targetGroupId ? { targetGroupId: opts.targetGroupId } : {}),
startupCommandDelivery: opts.startupCommandDelivery,
...(opts.identity
? {
@@ -181,10 +182,6 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca
}
const parentTabId = livePty.pty.tabId ?? `pty:${livePty.pty.ptyId}`
const leafId = parsePaneKey(livePty.pty.paneKey ?? '')?.leafId ?? randomUUID()
if (opts.viewMode) {
// Why: the runtime-owned binding must survive a serve restart with the same initial mode, not a later client's local default.
this.persistHeadlessSessionTabProps(worktreeId, parentTabId, { viewMode: opts.viewMode })
}
const existing = this.mobileSessionTabsByWorktree.get(worktreeId)
const existingSurface =
existing?.tabs.find(
@@ -29,7 +29,8 @@ export { getTerminalViewerColors, setPairedViewerColors } from './terminal-view-
export { normalizeColorQueryReplyColors } from '../../shared/pty-owner-color-query-colors'
export type { RuntimePtyController } from './runtime-pty-controller-contract'
export { getRuntimeDesktopSurface } from './runtime-desktop-surface'
export { runtimeNewTabPlacement } from './runtime-terminal-spawn-placement'
export { newTabIn } from './runtime-terminal-spawn-placement'
export { spawnInAdmittedPane } from './runtime-pane-admission'
export type { IpcMainEvent } from 'electron'
// Why initiallyHidden: no renderer pane exists yet, so main must answer startup queries — Muse
@@ -123,7 +123,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
try {
const { launchAgent } = launchOpts
launchOpts.onPtySpawnDispatched?.({ launchConfig: effectiveLaunchConfig, launchAgent })
result = await this.ptyController.spawn({
result = await dependencies.spawnInAdmittedPane(this.store, this.ptyController, {
cols: 120,
rows: 40,
cwd,
@@ -169,7 +169,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
? { onPtySpawnCommitted: reportPtySpawnCommitted }
: {}),
...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}),
placement: dependencies.runtimeNewTabPlacement(),
...dependencies.newTabIn(launchOpts),
...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}),
...(!adoptedBeforeLaunch && launchOpts.isNewSession ? { isNewSession: true } : {}),
...dependencies.BACKGROUND_TERMINAL_SPAWN_FLAGS
@@ -69,10 +69,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith
tabGroups: nextGroups,
tabs: nextTabs
}
this.persistHeadlessTerminalTabOrder(worktreeId, tabOrder)
if (nextGroups.length > 1 && snapshot.tabGroupLayout) {
this.persistHeadlessTabGroups(worktreeId, nextGroups, snapshot.tabGroupLayout)
}
this.persistHeadlessTabGroups(worktreeId, nextGroups, snapshot.tabGroupLayout)
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
return { moved: true }
@@ -155,18 +152,50 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith
return { moved: true }
}
// Persist the headless tab-GROUP layout so snapshot rebuilds keep the split.
// Persist the headless tab-GROUP layout so snapshot rebuilds keep the split; the tab bar and rows
// follow the groups. Deleted by the switch (PR 6), when moves are layout commands.
protected persistHeadlessTabGroups(
worktreeId: string,
groups: readonly RuntimeMobileSessionTabGroup[],
layout: TabGroupLayoutNode
layout: TabGroupLayoutNode | undefined
): void {
const session = this.getWorkspaceSessionForWorktree(worktreeId)
if (!session || !this.store?.setWorkspaceSession) {
return
}
const order = groups.flatMap((group) => group.tabOrder)
const groupIdByTabId = new Map(
groups.flatMap((group) => group.tabOrder.map((tabId) => [tabId, group.id] as const))
)
const position = (tabId: string): number => {
const index = order.indexOf(tabId)
return index === -1 ? order.length : index
}
const entries = session.unifiedTabs?.[worktreeId]
const rows = session.tabsByWorktree[worktreeId]
this.setWorkspaceSessionForWorktree(worktreeId, {
...session,
...(rows
? {
tabsByWorktree: {
...session.tabsByWorktree,
[worktreeId]: [...rows]
.sort((a, b) => position(a.id) - position(b.id) || a.sortOrder - b.sortOrder)
.map((row, sortOrder) => ({ ...row, sortOrder }))
}
}
: {}),
...(entries
? {
unifiedTabs: {
...session.unifiedTabs,
[worktreeId]: entries.map((entry) => ({
...entry,
groupId: groupIdByTabId.get(entry.id) ?? entry.groupId
}))
}
}
: {}),
tabGroups: {
...session.tabGroups,
[worktreeId]: groups.map((group) => ({
@@ -177,10 +206,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith
...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {})
}))
},
tabGroupLayouts: {
...session.tabGroupLayouts,
[worktreeId]: layout
}
...(layout ? { tabGroupLayouts: { ...session.tabGroupLayouts, [worktreeId]: layout } } : {})
})
}
}
@@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto'
import { runtimeSplitPlacement } from './runtime-terminal-spawn-placement'
import { REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS, ownerSurfacing } from './orca-runtime-core'
import type { Worktree } from '../../shared/worktree/types'
import { spawnInAdmittedPane, withdrawRuntimePane } from './runtime-pane-admission'
export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitTerminal {
protected async splitPtyBackedTerminal(
@@ -56,7 +57,7 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT
const leafId = randomUUID()
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
const paneKey = makePaneKey(parentTabId, leafId)
const result = await this.ptyController.spawn({
const splitArgs = {
cols: 120,
rows: 40,
cwd: workspace.path,
@@ -89,7 +90,8 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT
}
}
: {})
})
}
const result = await spawnInAdmittedPane(this.store, this.ptyController, splitArgs)
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
if (result.wslDistro) {
this.preparePtyExecutionContext(result.id, result.wslDistro)
@@ -150,17 +152,6 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT
revalidateSourceAuthority()
}
if (createdPty) {
const persisted = this.persistHeadlessTerminalSplit({
worktreeId: workspace.id,
tabId: parentTabId,
leafId,
ptyId: createdPty.ptyId,
splitFromLeafId: parsedPaneKey.leafId,
direction
})
if (sourceAuthority.persisted && !persisted) {
throw new Error('workspace_session_unavailable')
}
this.publishPtyBackedMobileSessionTerminal(workspace.id, createdPty, {
tabId: parentTabId,
leafId,
@@ -192,6 +183,7 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT
} catch {
// Best-effort cleanup; preserve the original split authority error.
}
await withdrawRuntimePane(this.store, splitArgs)
throw error
}
const committedSourceAuthority = sourceAuthority.persisted
@@ -1,3 +1,4 @@
import { withRuntimePaneAdmission } from './runtime-durable-store-fixture'
import { describe, expect, it, vi } from 'vitest'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types'
@@ -96,7 +97,7 @@ function createHarness(
addedAt: 1,
...(connectionId ? { connectionId } : {})
}
const store = {
const store = withRuntimePaneAdmission({
getRepos: () => [repo],
getRepo: (id: string) => (id === REPO_ID ? repo : undefined),
getWorkspaceSession: (hostId?: string) => {
@@ -107,7 +108,7 @@ function createHarness(
session = next
},
persistPtyBinding: () => true
}
})
let resolveSpawn: ((result: { id: string }) => void) | undefined
const spawn = options.deferSpawn
? vi.fn(
@@ -319,7 +320,6 @@ describe('remote runtime terminal split authority', () => {
[SOURCE_LEAF_ID]: SOURCE_PTY_ID
}
})
expect(Object.values(persistedLayout!.ptyIdsByLeafId!)).toContain(SPLIT_PTY_ID)
const siblingSurfaces = harness
.getSnapshot()!
.tabs.filter(
@@ -432,37 +432,20 @@ describe('remote runtime terminal split authority', () => {
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, true)
})
it('revalidates a projected paired-runtime source after renderer adoption', async () => {
const harness = createHarness(false, {
deferReveal: true,
includePairedSnapshot: true,
sourceIncarnationId: 'projected-before',
stopAndWaitResult: false
})
it('refuses a split whose source is only projected, before spawning', async () => {
const harness = createHarness(false, { includePairedSnapshot: true })
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce())
expect(harness.spawn).toHaveBeenCalledWith(
expect.not.objectContaining({ expectedSourceBinding: expect.anything() })
)
harness.replaceSourceIncarnation('projected-after')
harness.resolveReveal()
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.stopAndWait).toHaveBeenCalledWith(
SPLIT_PTY_ID,
expect.objectContaining({ deadlineMs: expect.any(Number) })
)
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, false)
await expect(
harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
).rejects.toThrow('terminal_split_source_not_found')
expect(harness.spawn).not.toHaveBeenCalled()
expect(harness.revealTerminalSession).not.toHaveBeenCalled()
})
it('preserves the split error when kill and retirement throw', async () => {
const harness = createHarness(false, {
deferReveal: true,
includePairedSnapshot: true,
sourceIncarnationId: 'projected-before',
it('preserves the split error and takes back the pane when kill and retirement throw', async () => {
const harness = createHarness(true, {
deferSpawn: true,
includePairedSnapshot: false,
stopAndWaitResult: false
})
harness.kill.mockImplementation(() => {
@@ -471,14 +454,19 @@ describe('remote runtime terminal split authority', () => {
harness.retireRejectedPty.mockImplementation(() => {
throw new Error('retire failed')
})
harness.replacePersistedSourceIncarnation('persisted-before')
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce())
harness.replaceSourceIncarnation('projected-after')
harness.resolveReveal()
await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce())
harness.replacePersistedSourceIncarnation('persisted-after')
harness.resolveSpawn()
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, false)
expect(harness.getSession().terminalLayoutsByTabId[TAB_ID]?.root).toEqual({
type: 'leaf',
leafId: SOURCE_LEAF_ID
})
})
})
@@ -1,4 +1,4 @@
import { withDurableRuntimeStore } from './runtime-durable-store-fixture'
import { withDurableRuntimeStore, withRuntimePaneAdmission } from './runtime-durable-store-fixture'
import { expect, vi } from 'vitest'
import { createHash } from 'node:crypto'
import { HeadlessEmulator } from '../daemon/headless-emulator'
@@ -580,46 +580,48 @@ function makeRuntimeStoreWithWorkspaceSession(
const setSession = (next: WorkspaceSessionState): void => {
session = next
}
const runtimeStore = withDurableRuntimeStore({
...store,
getWorkspaceSession: (hostId?: string) =>
hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession(),
setWorkspaceSession: vi.fn(setSession),
// Headless close is a durable transaction; keep the in-memory fixture's
// persistence contract equivalent to the production store.
flushOrThrow: vi.fn(),
persistPtyBinding: vi.fn<Store['persistPtyBinding']>(async (input) => {
const args = typeof input === 'function' ? input() : input
if (!args) {
return false
}
const tabs = session.tabsByWorktree[args.worktreeId] ?? []
session = {
...session,
tabsByWorktree: {
...session.tabsByWorktree,
[args.worktreeId]: tabs.map((tab) =>
tab.id === args.tabId ? { ...tab, ptyId: args.ptyId } : tab
)
},
terminalLayoutsByTabId: {
...session.terminalLayoutsByTabId,
[args.tabId]: {
...(session.terminalLayoutsByTabId[args.tabId] ?? {
root: { type: 'leaf', leafId: args.leafId },
activeLeafId: args.leafId,
expandedLeafId: null
}),
ptyIdsByLeafId: {
...session.terminalLayoutsByTabId[args.tabId]?.ptyIdsByLeafId,
[args.leafId]: args.ptyId
const runtimeStore = withRuntimePaneAdmission(
withDurableRuntimeStore({
...store,
getWorkspaceSession: (hostId?: string) =>
hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession(),
setWorkspaceSession: vi.fn(setSession),
// Headless close is a durable transaction; keep the in-memory fixture's
// persistence contract equivalent to the production store.
flushOrThrow: vi.fn(),
persistPtyBinding: vi.fn<Store['persistPtyBinding']>(async (input) => {
const args = typeof input === 'function' ? input() : input
if (!args) {
return false
}
const tabs = session.tabsByWorktree[args.worktreeId] ?? []
session = {
...session,
tabsByWorktree: {
...session.tabsByWorktree,
[args.worktreeId]: tabs.map((tab) =>
tab.id === args.tabId ? { ...tab, ptyId: args.ptyId } : tab
)
},
terminalLayoutsByTabId: {
...session.terminalLayoutsByTabId,
[args.tabId]: {
...(session.terminalLayoutsByTabId[args.tabId] ?? {
root: { type: 'leaf', leafId: args.leafId },
activeLeafId: args.leafId,
expandedLeafId: null
}),
ptyIdsByLeafId: {
...session.terminalLayoutsByTabId[args.tabId]?.ptyIdsByLeafId,
[args.leafId]: args.ptyId
}
}
}
}
}
return true
return true
})
})
})
)
return { runtimeStore, getSession: () => session, setSession }
}
@@ -176,4 +176,55 @@ describe('OrcaRuntimeService', () => {
expect(listed.tabGroups).toHaveLength(1)
expect(storedActiveGroupId(runtime)).toBe(listed.tabGroups?.[0]?.id)
})
it('keeps phone and CLI creates in the group they joined across a cold restart', async () => {
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal({
activeTabId: 'serve-tab',
activeTabIdByWorktree: { [TEST_WORKTREE_ID]: 'serve-tab' },
activeGroupIdByWorktree: { [TEST_WORKTREE_ID]: 'group-left' },
tabGroups: { [TEST_WORKTREE_ID]: SPLIT_GROUPS },
tabsByWorktree: {
[TEST_WORKTREE_ID]: [
makeTerminalTab('serve-tab', SERVE_PTY_ID, 0),
makeTerminalTab('daemon-tab', DAEMON_PTY_ID, 1)
]
},
terminalLayoutsByTabId: {
'serve-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: SERVE_PTY_ID }),
'daemon-tab': makeHeadlessTerminalLayout({ [HEADLESS_SECOND_LEAF_ID]: DAEMON_PTY_ID })
}
})
)
const runtime = new OrcaRuntimeService(runtimeStore)
let ptys = 0
runtime.setPtyController({
spawn: vi.fn(async () => ({ id: `created-pty-${++ptys}` })),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => []
})
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
// The phone targets the right group; an untargeted CLI create joins the first, as on main.
const phone = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
targetGroupId: 'group-right'
})
const cli = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`)
const tabOrderOf = (session: WorkspaceSessionState, groupId: string) =>
session.tabGroups?.[TEST_WORKTREE_ID]?.find((group) => group.id === groupId)?.tabOrder
expect(tabOrderOf(getSession(), 'group-right')).toContain(phone.tab.parentTabId)
expect(tabOrderOf(getSession(), 'group-left')).toContain(cli.tabId)
const restarted = new OrcaRuntimeService(
makeRuntimeStoreWithWorkspaceSession(getSession()).runtimeStore
)
const listed = await restarted.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(listed.tabGroups?.map((group) => group.tabOrder)).toEqual([
['serve-tab', cli.tabId],
['daemon-tab', phone.tab.parentTabId]
])
})
})
@@ -13,8 +13,10 @@ import {
TEST_WORKTREE_PATH,
UUID_RE,
deferred,
makeRuntimeStoreWithWorkspaceSession,
store
} from '../orca-runtime-test-fixtures.spec'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
describe('OrcaRuntimeService', () => {
it('keeps client selection when a renderer session-tab close cannot commit', async () => {
@@ -249,17 +251,10 @@ describe('OrcaRuntimeService', () => {
it('creates mobile session terminals in a headless runtime server', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-headless' })
const runtime = new OrcaRuntimeService(store)
const persistViewMode = vi.spyOn(
runtime as unknown as {
persistHeadlessSessionTabProps: (
worktreeId: string,
tabId: string,
props: { viewMode: 'terminal' | 'chat' }
) => void
},
'persistHeadlessSessionTabProps'
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
getDefaultWorkspaceSession()
)
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
spawn,
write: () => true,
@@ -289,9 +284,12 @@ describe('OrcaRuntimeService', () => {
viewMode: 'chat',
isActive: true
})
expect(persistViewMode).toHaveBeenCalledWith(TEST_WORKTREE_ID, result.tab.parentTabId, {
viewMode: 'chat'
})
// Written with the pane before spawn, so a serve restart keeps the initial mode.
expect(
getSession().tabsByWorktree[TEST_WORKTREE_ID]?.find(
(tab) => tab.id === result.tab.parentTabId
)?.viewMode
).toBe('chat')
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(listed.tabs).toEqual([
@@ -1,3 +1,4 @@
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import { describe, expect, it, vi } from 'vitest'
import {
OrcaRuntimeService,
@@ -10,7 +11,11 @@ import {
runHook,
shouldRunSetupForCreate
} from '../orca-runtime-test-mocks.spec'
import { expectStablePaneKeyEnv, store } from '../orca-runtime-test-fixtures.spec'
import {
expectStablePaneKeyEnv,
store,
makeRuntimeStoreWithWorkspaceSession
} from '../orca-runtime-test-fixtures.spec'
describe('OrcaRuntimeService', () => {
it('sequences setup before startup for opted-in local headless worktree creates', async () => {
@@ -517,7 +522,7 @@ describe('OrcaRuntimeService', () => {
it('honors split setup placement for CLI-created worktrees without startup agents', async () => {
const runtimeStore = {
...store,
...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore,
getSettings: () => ({
...store.getSettings(),
setupScriptLaunchMode: 'split-vertical' as const
@@ -1,3 +1,4 @@
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import {
OrcaRuntimeService,
@@ -14,12 +15,17 @@ import {
shouldRunSetupForCreate
} from '../orca-runtime-test-mocks.spec'
import type { WorktreeMeta } from '../orca-runtime-test-mocks.spec'
import { TEST_REPO_ID, makeWorktreeMeta, store } from '../orca-runtime-test-fixtures.spec'
import {
TEST_REPO_ID,
makeWorktreeMeta,
store,
makeRuntimeStoreWithWorkspaceSession
} from '../orca-runtime-test-fixtures.spec'
describe('OrcaRuntimeService', () => {
it('does not surface the new workspace when a background create splits its setup pane', async () => {
const runtimeStore = {
...store,
...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore,
getSettings: () => ({
...store.getSettings(),
setupScriptLaunchMode: 'split-horizontal' as const
@@ -102,7 +108,7 @@ describe('OrcaRuntimeService', () => {
it('still surfaces the new workspace when an activating create splits its setup pane', async () => {
const runtimeStore = {
...store,
...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore,
getSettings: () => ({
...store.getSettings(),
setupScriptLaunchMode: 'split-horizontal' as const
@@ -1,3 +1,4 @@
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import { describe, expect, it, vi } from 'vitest'
import {
OrcaRuntimeService,
@@ -14,7 +15,12 @@ import {
shouldRunSetupForCreate
} from '../orca-runtime-test-mocks.spec'
import type { WorktreeMeta } from '../orca-runtime-test-mocks.spec'
import { TEST_REPO_ID, makeWorktreeMeta, store } from '../orca-runtime-test-fixtures.spec'
import {
TEST_REPO_ID,
makeWorktreeMeta,
store,
makeRuntimeStoreWithWorkspaceSession
} from '../orca-runtime-test-fixtures.spec'
describe('OrcaRuntimeService', () => {
it('sends follow-up prompts for CLI-created stdin-after-start startup agents', async () => {
@@ -233,7 +239,7 @@ describe('OrcaRuntimeService', () => {
it('honors split setup placement for opted-in local startup-draft worktrees', async () => {
const metaById: Record<string, WorktreeMeta> = {}
const runtimeStore = {
...store,
...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore,
getSettings: () => ({
...store.getSettings(),
defaultTuiAgent: 'codex' as const,
@@ -1,5 +1,14 @@
import type { DurableProfileStateMutation } from '../persistence/loading-store/store-runtime-state'
import { profileStateWriterFailureOutcome } from '../persistence/profile-state/profile-state-writer-errors'
import { resolveHostId } from '../persistence/loading-store/session-host-partitions'
import {
admitPaneToSession,
withdrawPaneFromSession,
type PaneLayoutChange,
type TerminalPaneAdmission
} from '../persistence/terminal-topology/terminal-pane-admission'
import type { CommandOf } from '../../shared/workspace-layout/workspace-layout-command-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
/** Keep runtime fakes on the same reserved, durable-before-ack contract as Store. */
export function withDurableRuntimeStore<
@@ -38,3 +47,29 @@ export function withDurableRuntimeStore<
}
})
}
/** Gives a runtime fake the Store's pane admission, over the fake's own session accessors. */
export function withRuntimePaneAdmission<
T extends {
getWorkspaceSession?: (hostId?: string) => WorkspaceSessionState
setWorkspaceSession?: (session: WorkspaceSessionState, hostId?: string) => void
}
>(store: T) {
const commit = <V>(
hostId: string | undefined,
change: (session: WorkspaceSessionState) => PaneLayoutChange<V>
): V => {
const resolved = resolveHostId(hostId)
const { value, session } = change(store.getWorkspaceSession!(resolved))
if (session) {
store.setWorkspaceSession!(session, resolved)
}
return value
}
return Object.assign(store, {
admitTerminalPane: async (admission: TerminalPaneAdmission, hostId?: string) =>
commit(hostId, (session) => admitPaneToSession(resolveHostId(hostId), session, admission)),
withdrawTerminalPane: async (pane: CommandOf<'closePane'>, hostId?: string) =>
commit(hostId, (session) => withdrawPaneFromSession(resolveHostId(hostId), session, pane))
})
}
@@ -0,0 +1,87 @@
import { toSshExecutionHostId } from '../../shared/execution-host'
import type { TerminalPaneAdmission } from '../persistence/terminal-topology/terminal-pane-admission'
import type { RuntimePtyController } from './runtime-pty-controller-contract'
import type { RuntimeStore } from './runtime-store-contract'
type Spawn = NonNullable<RuntimePtyController['spawn']>
type AdmittedSpawnArgs = Parameters<Spawn>[0] & {
worktreeId: string
tabId: string
leafId: string
/** The group a new tab joins; a split stays in its parent's. */
groupId?: string
}
/** The pane its placement names: a new tab, or a split beside its parent pane. */
function admissionFor(args: AdmittedSpawnArgs): TerminalPaneAdmission {
const { worktreeId: workspace, tabId, leafId, placement } = args
if (placement?.kind === 'split') {
const { parentLeafId, direction } = placement
return {
type: 'splitPane',
workspace,
tabId,
leafId: parentLeafId,
direction,
newLeafId: leafId
}
}
const viewMode = placement?.kind === 'new-tab' ? placement.row?.viewMode : undefined
return {
type: 'createTerminalTab',
workspace,
tabId,
leafId,
...(args.groupId ? { groupId: args.groupId } : {}),
...(viewMode ? { viewMode } : {}),
...(args.cwd ? { creation: { startupCwd: args.cwd } } : {})
}
}
function hostIdOf(args: AdmittedSpawnArgs): string | undefined {
return args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined
}
/** Takes back a pane the runtime wrote for a start that then failed, so it leaves no tab. */
export async function withdrawRuntimePane(
store: RuntimeStore | null | undefined,
args: AdmittedSpawnArgs
): Promise<void> {
await store?.withdrawTerminalPane?.(
{ type: 'closePane', workspace: args.worktreeId, tabId: args.tabId, leafId: args.leafId },
hostIdOf(args)
)
}
/**
* Starts a runtime-originated terminal (design 4.1): its pane, tab-bar entry and group are written
* first, then the process starts and binds to that pane. A start that fails, or that attaches to a
* session already running in another pane, takes back the pane it wrote.
*/
export async function spawnInAdmittedPane(
store: RuntimeStore | null | undefined,
controller: Required<Pick<RuntimePtyController, 'spawn'>>,
args: AdmittedSpawnArgs
): ReturnType<Spawn> {
const outcome = await store?.admitTerminalPane?.(admissionFor(args), hostIdOf(args))
if (outcome !== undefined && outcome !== 'admitted' && outcome !== 'exists') {
// Only a split is refused: its parent pane left the layout.
throw new Error('terminal_split_source_not_found')
}
const { groupId: _groupId, ...spawnArgs } = args
let result: Awaited<ReturnType<Spawn>>
try {
result = await controller.spawn(spawnArgs)
} catch (error) {
if (outcome === 'admitted') {
await withdrawRuntimePane(store, args)
}
throw error
}
const started = result.agentSessionEnsure?.owner.surface ?? result.stablePaneOwner
const elsewhere = started && (started.tabId !== args.tabId || started.leafId !== args.leafId)
if (outcome === 'admitted' && elsewhere) {
await withdrawRuntimePane(store, args)
}
return result
}
@@ -58,6 +58,8 @@ export type RuntimeStore = {
abortStagedOrcadMigrationCatalog?: Store['abortStagedOrcadMigrationCatalog']
getOrcadMigrationCatalogState?: Store['getOrcadMigrationCatalogState']
persistPtyBinding?: Store['persistPtyBinding']
admitTerminalPane?: Store['admitTerminalPane']
withdrawTerminalPane?: Store['withdrawTerminalPane']
getSshRemotePtyLeases?: Store['getSshRemotePtyLeases']
getUI?: Store['getUI']
updateUI?: Store['updateUI']
@@ -67,6 +67,8 @@ export type TerminalCreateOptions = {
terminalKittyKeyboardProtocol?: boolean
terminalColorQueryReplies?: TerminalOscColorQueryReplyColors
viewMode?: 'terminal' | 'chat'
/** The tab group a background create joins; defaults to the first group. */
targetGroupId?: string
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
telemetry?: WorktreeStartupLaunch['telemetry']
/** The surface that asked for this `startupAgent` launch; the runtime attributes every one it
@@ -47,8 +47,7 @@ export function buildRuntimeTerminalOrphanSession(args: {
customTitle: null,
color: null,
sortOrder: tabsById.size,
createdAt: Date.now(),
pendingActivationSpawn: true
createdAt: Date.now()
}
tabsById.set(claim.tabId, tab)
}
@@ -2,8 +2,20 @@ import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement
import type { TerminalPaneSplitDirection } from '../../shared/terminal-tab-types'
/** A main-side create always opens its own tab. */
export function runtimeNewTabPlacement(): TerminalPanePlacement {
return { kind: 'new-tab' }
export function runtimeNewTabPlacement(viewMode?: 'terminal' | 'chat'): TerminalPanePlacement {
return viewMode ? { kind: 'new-tab', row: { viewMode } } : { kind: 'new-tab' }
}
/** A runtime-started tab opens in the group asked for, else the first group (as the live view). */
export function newTabIn(opts: { viewMode?: 'terminal' | 'chat'; targetGroupId?: string }): {
placement: TerminalPanePlacement
groupId?: string
} {
const { targetGroupId } = opts
return {
placement: runtimeNewTabPlacement(opts.viewMode),
...(targetGroupId ? { groupId: targetGroupId } : {})
}
}
export function runtimeSplitPlacement(
@@ -17,6 +17,9 @@ type On = { workspace: string }
export type LayoutCommand =
| (On & {
type: 'createTerminalTab'
/** Ids the runtime minted before applying, e.g. a retried create's. */
tabId?: string
leafId?: string
groupId?: string
afterTabId?: string
index?: number
@@ -31,6 +34,8 @@ export type LayoutCommand =
leafId: string
direction: TerminalPaneSplitDirection
ratio?: number
/** The new pane's id, when the runtime minted it before applying. */
newLeafId?: string
})
| (On & { type: 'closePane'; tabId: string; leafId: string })
| (On & {
@@ -123,8 +123,8 @@ const ROW = {
startupCwd: own('tab.terminal', 'startupCwd'),
launchAgent: own('tab.terminal', 'launchAgent'),
agentLaunchPane: own('tab.terminal', 'agentLaunchPane'),
// Transient handoffs, never restored; the window strips them, but main's minimal row mint
// stores `pendingActivationSpawn`, so the Loader drops it.
// Transient handoffs, never restored; the window strips them, but older builds' minimal row mint
// stored `pendingActivationSpawn`, so the Loader drops it.
pendingActivationSpawn: 'unwritten',
recovery: 'unwritten',
restoredFromSession: 'unwritten'
@@ -46,7 +46,7 @@ export function splitPane(
if (!layoutContainsLeafId(tab.panes.root, command.leafId)) {
return refuse('pane_not_found')
}
const leafId = context.mintLeafId()
const leafId = command.newLeafId ?? context.mintLeafId()
const side = command.direction === 'vertical' ? 'right' : 'bottom'
const root = insertLeafBeside(tab.panes.root!, command.leafId, leafId, side, command.ratio)
const next = updateTab(model, command.workspace, { ...tab, panes: { ...tab.panes, root } })
@@ -42,7 +42,7 @@ export const KNOWN_LOAD_CHANGES: ReadonlySet<string> = new Set([
'group.recentTabIds',
'row.title',
'row.ptyId',
// Transient handoffs main's minimal row mint stores; never restored.
// Transient handoffs older builds' minimal row mint stored; never restored.
'row.pendingActivationSpawn',
'row.recovery',
'row.customTitle',
@@ -40,8 +40,8 @@ export function createTerminalTab(
const ordinal = getNextTerminalOrdinal(
terminals.map((tab) => ({ defaultTitle: tab.terminal.defaultTitle, title: '' }))
)
const id = context.mintId()
const leafId = context.mintLeafId()
const id = command.tabId ?? context.mintId()
const leafId = command.leafId ?? context.mintLeafId()
const tab: LayoutTerminalTab = {
id,
entityId: id,
@@ -97,10 +97,10 @@ async function handleForTab(run: Run, tabId: string): Promise<string> {
return handle!
}
async function createTab(run: Run, client: RuntimeClient): Promise<string> {
async function createTab(run: Run, client: RuntimeClient, targetGroupId?: string): Promise<string> {
const created = await client.call<{ tab: { parentTabId: string } }>(
'session.tabs.createTerminal',
{ worktree: run.worktree }
{ worktree: run.worktree, ...(targetGroupId ? { targetGroupId } : {}) }
)
return created.result.tab.parentTabId
}
@@ -304,6 +304,42 @@ const SCENARIOS: Scenario[] = [
await run.restart('warm restart', [1, 1])
}
},
{
// #27083 review: a phone create joins the group it targets, and an untargeted CLI create the
// first group, across a cold restart.
id: 'create-in-second-group-cold-restart',
journey: async (run) => {
const first = await createTab(run, run.a)
const second = await createTab(run, run.a)
await run.check('two tabs', [1, 1])
const [source] = await groupsOf(run)
const [, target] = await moveTab(
run,
'split second tab right',
{ kind: 'split', tabId: second, targetGroupId: source!.id, splitDirection: 'right' },
[[first], [second]]
)
const phone = await createTab(run, run.a, target!.id)
await run.check('phone create in second group', [1, 1, 1])
const membership = async () =>
JSON.stringify((await groupsOf(run)).map((group) => group.tabOrder.toSorted()))
const joined = await membership()
const phoneExpected = JSON.stringify([[first], [second, phone].toSorted()])
run.expectNone(
'phone create joins the second group',
joined === phoneExpected ? [] : [`groups ${joined}, expected ${phoneExpected}`]
)
const cli = (await createTerminal(run)).tabId!
await run.check('CLI create in first group', [1, 1, 1, 1])
await run.restart('cold restart', [1, 1, 1, 1], true)
const after = await membership()
const expected = JSON.stringify([[first, cli].toSorted(), [second, phone].toSorted()])
run.expectNone(
'created tabs stay in their groups after a cold restart',
after === expected ? [] : [`groups ${after}, expected ${expected}`]
)
}
},
{
// Moves between groups and a reorder; a group emptied by a move closes.
id: 'move-tab-between-groups',
@@ -84,20 +84,6 @@ export const LAYOUT_ORACLE_KNOWN_ON_MAIN: readonly KnownOnMain[] = [
step: 'relaunch',
cause: 'tab bar not saved after wake'
},
// Headless runtimes save tab rows and no tab bar; a close then saves a tab bar that lacks the
// tabs still open.
{
scenario: 'headless-*',
check: 'rules',
rule: 'tab_bar_missing',
cause: 'headless saves no tab bar'
},
{
scenario: 'headless-*',
check: 'rules',
rule: 'tab_lists_disagree',
cause: 'headless close saves a partial tab bar'
},
// After a relaunch main holds the tab rows but no tab bar for the worktree until the window
// saves again; slow enough on Linux CI to outlast the settle wait.
...(['tab_lists_disagree', 'tab_bar_missing'] as const).flatMap((rule) =>
@@ -132,16 +118,15 @@ export const LAYOUT_ORACLE_KNOWN_ON_MAIN: readonly KnownOnMain[] = [
step: 'rename after restart',
cause: 'orcad title after restart'
},
// Headless group moves save the groups but still no tab bar, so the groups list tabs the tab bar
// lacks.
...(['orcad', 'electron'] as const).flatMap((kind) =>
['split-tab-into-new-group', 'move-tab-between-groups'].map((id) => ({
scenario: `headless-${kind}-${id}`,
check: 'rules' as const,
rule: 'group_lists_missing_tab',
cause: 'headless saves groups but no tab bar'
}))
),
// A headless reorder saves rows through the session merge door, which keeps the stored row order,
// so rows and the group disagree; hidden until creates saved a tab bar. Fixed in PR 6 with
// client-reorder-tabs.
...(['orcad', 'electron'] as const).map((kind) => ({
scenario: `headless-${kind}-move-tab-between-groups`,
check: 'rules' as const,
rule: 'tab_order_disagrees',
cause: 'one tab order'
})),
// After a headless move into an existing group, paired clients still see that group's previous
// tab selected, not the moved one. A split into a new group selects the moved tab.
...(['orcad', 'electron'] as const).map((kind) => ({