Window saves stop carrying tab and pane layout; main keeps its own (dark, layout-owner build only) (#30282)

* fix(layout): an SSH pane's terminal binds in its own session partition (dark, layout-owner build only)

Main keeps its own layout over a window save, so a binding only a save carries is lost. A window
or runtime spawn of an SSH pane bound in the local partition; it now binds where the pane's layout
lives.

(cherry picked from commit beb2275b45)

* test(layout): the SSH binding test uses the shared spawn commit dependencies

(cherry picked from commit 672b641897)

* fix(layout): main moves a tab between a local and an SSH workspace itself (dark, layout-owner build only)

moveTabToWorkspace across two partitions was refused as other_partition and left to the window's
save. Main now reads both partitions, moves the tabs as within one, and writes both in one durable
mutation.

(cherry picked from commit ee27a0bb07)

* chore(layout): one binding write for the partition choice; shipped builds keep their exact replaced-binding read

* fix(layout): window saves keep main's layout and give it only the window's view and facts (dark, layout-owner build only)

windowViewSaveForMain/windowViewPatchForMain replace windowSaveForMain: main's own layout joined
with the window's view, facts and carried fields, written only into the records that change. A
paired server's partition passes through. A visited workspace main holds keeps its terminal row
(D1). The P10 pending case is on.

(cherry picked from commit 0c2fc15974)

* fix(layout): the save divergence detector becomes a report-only drift check behind its log (dark, layout-owner build only)

It no longer wraps the write: it compares the window's raw save with main's layout model, and
only when ORCA_LAYOUT_SAVE_DIVERGENCE_LOG is set.

(cherry picked from commit 6bc1e5556f)

* fix(layout): tab reconcile and terminal reconnect stop writing tab structure (dark, layout-owner build only)

Main's frames draw the tab bar and rows: reconcile still returns its counts but writes nothing,
and reconnect still advertises restored terminals but leaves the rows to main.

(cherry picked from commit ef8eb625ca)

* fix(layout): a layout command main cannot apply is a logged refusal, not the window's own change (dark, layout-owner build only)

other_partition, a thrown call, and host_not_owned for a workspace that is not a paired server's
are refused and logged: a window save no longer carries the change, so drawing it would last only
until the next frame. A twin main may route to a paired server keeps today's path, as does the
first terminal's host_not_owned fallback.

(cherry picked from commit 2c513b2d10)

* fix(layout): the window's refusal code type keeps main's host refusals

(cherry picked from commit 36d46bdcc2)

* fix(layout): the quit checkpoint records only the quit capture's resume records in main (dark, layout-owner build only)

Main keeps its own resume records over a window save, so the quit capture's changes, which reach
main as async commands, could miss the quit. The capture now sends only what it changed with the
synchronous stage, and main writes them through the record-only sleep command before staging; a
wake main recorded that the window had not seen stays.

(cherry picked from commit 2a0f433c34)

* test(layout): the quit capture test builds a typed resume record

(cherry picked from commit d92d1246ee)

* chore(layout): one layout write per save handler

* chore(layout): shipped builds keep their exact quit capture

* fix(layout): the drift check compares structure only, not records main writes itself (dark, layout-owner build only)

Pane bindings and resume records, default-tab marks, closed-tab records and terminal row owners
are main's own writes; a save that has not caught up with them asks main for nothing, so they are
no longer reported.

(cherry picked from commit 93e8b2627c)

* test(layout): relaunch first-terminal cases, and the side-by-side resume case moves its tab the way the window does

(cherry picked from commit ab381332b1)

* test(layout): the relaunch case also checks a reopen that cleared read-only

(cherry picked from commit bbf3d040c8)

* docs(layout): the drift check names the record kinds it ignores

* revert(layout): drop the SSH binding partition change; SSH terminal starts are refused before any binding is saved

getProvider refuses every non-local host, so neither spawn path reaches persistPtyBinding for an
SSH pane, with the switch on or off.

* test: run A2's two real-SQLite session tests in the Node runtime project
This commit is contained in:
Jinwoo Hong
2026-10-18 02:56:38 -07:00
committed by GitHub
parent 10505696a2
commit fc24ac3715
26 changed files with 1063 additions and 764 deletions
@@ -106,6 +106,8 @@ export const NODE_RUNTIME_INCLUDE = [
'src/main/hermes/hermes-config-remote-adapter.test.ts',
'src/main/ipc/orca-profiles-switch-persistence.test.ts',
'src/main/ipc/settings-delayed-response.test.ts',
'src/main/ipc/session-window-view-save.test.ts',
'src/main/ipc/renderer-shutdown-checkpoint-sleep-records.test.ts',
'src/main/runtime/structured-agent-session-startup-tab-restore.test.ts',
'src/main/runtime/structured-agent-session-windowless-layout-host.test.ts',
'src/shared/remote-runtime-shared-control-connection.test.ts',
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { closeTestStores, createStore, testState } from '../persistence-test-harness'
import {
addWorkspace,
emptySession,
GIT_KEY,
leaf
} from '../../shared/workspace-layout/workspace-layout-session.test-fixture'
import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
const { handlers } = vi.hoisted(() => ({
handlers: new Map<string, (event: { returnValue?: unknown }, args: unknown) => unknown>()
}))
vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false },
ipcMain: {
handle: (
channel: string,
handler: (event: { returnValue?: unknown }, args: unknown) => unknown
) => {
handlers.set(channel, handler)
},
on: (
channel: string,
handler: (event: { returnValue?: unknown }, args: unknown) => unknown
) => {
handlers.set(channel, handler)
}
}
}))
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) }))
vi.mock('../ssh/ssh-config-parser', () => ({
loadUserSshConfig: vi.fn(),
sshConfigHostsToTargets: vi.fn()
}))
import { registerRendererShutdownCheckpointHandler } from './renderer-shutdown-checkpoint'
const PANE_A = `tab-a:${leaf(1)}`
const PANE_B = `tab-b:${leaf(2)}`
function session(): WorkspaceSessionState {
return addWorkspace(emptySession(), GIT_KEY, [
{
id: 'group-1',
tabs: [
{ id: 'tab-a', leaves: [[leaf(1), 'pty-1']] },
{ id: 'tab-b', leaves: [[leaf(2), 'pty-2']] }
]
}
])
}
function record(paneKey: string, capturedAt: number): SleepingAgentSessionRecord {
return {
paneKey,
tabId: paneKey.split(':')[0],
worktreeId: GIT_KEY,
agent: 'claude',
providerSession: { key: 'session_id', id: `session-${capturedAt}` },
prompt: 'work',
state: 'working',
capturedAt,
updatedAt: capturedAt
}
}
function stage(state: WorkspaceSessionState, sleepRecords?: SleepingAgentSessionRecord[]) {
const event: { returnValue?: unknown } = {}
handlers.get('app:stage-before-unload-sync')!(event, {
sessions: [{ state, hostId: 'local' }],
ui: {},
...(sleepRecords ? { sleepRecords } : {})
})
return event.returnValue
}
describe('the quit checkpoint in a layout-owner build', () => {
let store: ReturnType<typeof createStore>
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
store = createStore()
registerRendererShutdownCheckpointHandler(store)
})
afterEach(async () => {
await closeTestStores()
rmSync(testState.dir, { recursive: true, force: true })
})
it('records the quit capture’s resume records in main before staging', () => {
store.setWorkspaceSession(session(), 'local')
const captured = record(PANE_A, 2)
expect(
stage({ ...session(), sleepingAgentSessionsByPaneKey: { [PANE_A]: captured } }, [captured])
).toEqual({ ok: true })
expect(store.getWorkspaceSession('local').sleepingAgentSessionsByPaneKey?.[PANE_A]).toEqual(
captured
)
})
it('keeps a wake main recorded that the window had not seen yet', () => {
// Main woke tab-b (a phone showed it) after the window last saw its record.
store.setWorkspaceSession(session(), 'local')
const captured = record(PANE_A, 2)
stage(
{
...session(),
sleepingAgentSessionsByPaneKey: { [PANE_A]: captured, [PANE_B]: record(PANE_B, 1) }
},
[captured]
)
const records = store.getWorkspaceSession('local').sleepingAgentSessionsByPaneKey ?? {}
expect(Object.keys(records)).toEqual([PANE_A])
})
})
+61 -23
View File
@@ -1,16 +1,53 @@
import { ipcMain } from 'electron'
import type { ExecutionHostId } from '../../shared/execution-host'
import {
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume'
import { applyCommandToSession } from '../persistence/terminal-topology/terminal-pane-admission'
import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import type { Store } from '../persistence'
import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source'
import { windowSaveForMain } from '../persistence/window-save-pane-trees'
import { writeWindowSaveWatchingLayout } from '../persistence/window-save-divergence'
import { windowViewSaveForMain } from '../persistence/window-save-view'
import { reportWindowSaveDrift } from '../persistence/window-save-divergence'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
type StageBeforeUnloadSyncArgs = {
sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[]
ui: Partial<PersistedUIState>
/** Layout-owner builds: the resume records the quit capture changed. */
sleepRecords?: SleepingAgentSessionRecord[]
}
/**
* The partition with the quit capture's resume records written, each workspace's through the
* record-only sleep command a live capture sends: only what the capture changed, so a wake main
* recorded after the window last looked stays. Records of panes the partition lacks change nothing.
*/
function withQuitSleepRecords(
hostId: string | undefined,
session: WorkspaceSessionState,
records: readonly SleepingAgentSessionRecord[]
): WorkspaceSessionState {
const byWorkspace = Map.groupBy(records, (record) => record.worktreeId)
let current = session
for (const [workspace, workspaceRecords] of byWorkspace) {
const change = applyCommandToSession(
normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID,
current,
{
type: 'sleep',
workspace,
paneKeys: workspaceRecords.map((record) => record.paneKey),
records: workspaceRecords,
recordOnly: true
}
)
current = change.session ?? current
}
return current
}
export type ShutdownCheckpointResult = { ok: boolean }
@@ -44,6 +81,21 @@ function flushStagedStateWithDeadline(store: Store): Promise<ShutdownCheckpointR
})
}
/**
* Switch point: main keeps its own layout over the window's and takes its view and facts, with the
* quit capture's resume records written into main's first.
*/
function stagedSessionForMain(
store: Store,
state: WorkspaceSessionState,
hostId: ExecutionHostId | undefined,
sleepRecords: readonly SleepingAgentSessionRecord[] = []
): WorkspaceSessionState {
const stored = withQuitSleepRecords(hostId, store.getWorkspaceSession(hostId), sleepRecords)
reportWindowSaveDrift(stored, state, hostId, 'shutdown-checkpoint')
return windowViewSaveForMain(stored, state, hostId)
}
export function registerRendererShutdownCheckpointHandler(store: Store): void {
// Why: beforeunload cannot await, so the sync reply only reports staging.
// Durability is joined out-of-band by paths that are about to navigate.
@@ -54,26 +106,12 @@ export function registerRendererShutdownCheckpointHandler(store: Store): void {
try {
for (const { state, hostId } of args.sessions) {
if (!isFrozenOrcadSourceSessionPartition(store, hostId)) {
// Switch point: main keeps its own pane trees over the window's, and reports any layout
// the save still changes.
const stage = (): void =>
store.stageWorkspaceSessionBeforeUnload(
ORCA_LAYOUT_OWNER_IS_MAIN
? windowSaveForMain(store.getWorkspaceSession(hostId), state, hostId)
: state,
hostId
)
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
writeWindowSaveWatchingLayout(
() => store.getWorkspaceSession(hostId),
hostId,
stage,
'shutdown-checkpoint',
state.terminalLayoutsByTabId
)
} else {
stage()
}
store.stageWorkspaceSessionBeforeUnload(
ORCA_LAYOUT_OWNER_IS_MAIN
? stagedSessionForMain(store, state, hostId, args.sleepRecords)
: state,
hostId
)
}
}
store.updateUI(args.ui)
@@ -0,0 +1,175 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { closeTestStores, createStore, testState } from '../persistence-test-harness'
import {
addWorkspace,
emptySession,
GIT_KEY,
leaf
} from '../../shared/workspace-layout/workspace-layout-session.test-fixture'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
const { handlers } = vi.hoisted(() => ({
handlers: new Map<string, (event: unknown, ...args: unknown[]) => unknown>()
}))
vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false },
ipcMain: {
handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, handler)
},
on: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, handler)
}
}
}))
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) }))
vi.mock('../ssh/ssh-config-parser', () => ({
loadUserSshConfig: vi.fn(),
sshConfigHostsToTargets: vi.fn()
}))
import { registerSessionHandlers } from './session'
const OTHER_KEY = 'repo-1::/Users/dev/other'
/** Main's partition: one workspace with two terminal tabs, the first split in two panes. */
function mainSession(): WorkspaceSessionState {
return addWorkspace(emptySession(), GIT_KEY, [
{
id: 'group-1',
tabs: [
{
id: 'tab-a',
leaves: [
[leaf(1), 'pty-1'],
[leaf(2), 'pty-2']
]
},
{ id: 'tab-b', leaves: [[leaf(3), 'pty-3']] }
]
}
])
}
/** What a window that lags main saves: tab-b closed, a tab main never got, one pane, new titles. */
function laggingWindowSave(): WorkspaceSessionState {
const save = addWorkspace(emptySession(), GIT_KEY, [
{
id: 'group-1',
tabs: [
{ id: 'tab-a', title: 'vim', leaves: [[leaf(1), 'pty-1']] },
{ id: 'tab-new', leaves: [[leaf(5), 'pty-5']] }
]
}
])
return {
...save,
activeWorktreeId: GIT_KEY,
lastVisitedAtByWorktreeId: { [GIT_KEY]: 5, [OTHER_KEY]: 6 }
}
}
function terminalTabs(session: WorkspaceSessionState, key = GIT_KEY): string[] {
return (session.tabsByWorktree[key] ?? []).map((row) => row.id)
}
async function call(channel: string, ...args: unknown[]): Promise<void> {
await handlers.get(channel)!({}, ...args)
}
describe('a window save in a layout-owner build', () => {
let store: ReturnType<typeof createStore>
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
store = createStore()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the save handlers never reach the runtime.
registerSessionHandlers(store, {} as never)
})
afterEach(async () => {
await closeTestStores()
rmSync(testState.dir, { recursive: true, force: true })
})
it('keeps main’s tabs and panes, and takes the window’s view and titles', async () => {
store.setWorkspaceSession(mainSession(), 'local')
await call('session:set', laggingWindowSave(), 'local')
const saved = store.getWorkspaceSession('local')
expect(terminalTabs(saved)).toEqual(['tab-a', 'tab-b'])
expect(saved.unifiedTabs?.[GIT_KEY]?.map((entry) => entry.id)).toEqual(['tab-a', 'tab-b'])
expect(saved.terminalLayoutsByTabId['tab-a']?.root).toEqual(
mainSession().terminalLayoutsByTabId['tab-a']!.root
)
expect(saved.terminalLayoutsByTabId['tab-new']).toBeUndefined()
expect(saved.tabsByWorktree[GIT_KEY]?.[0]?.title).toBe('vim')
expect(saved.activeWorktreeId).toBe(GIT_KEY)
})
it('takes a scrollback patch into main’s panes and leaves main’s tree', async () => {
store.setWorkspaceSession(mainSession(), 'local')
const windowLayout = laggingWindowSave().terminalLayoutsByTabId['tab-a']!
await call(
'session:patch',
{
terminalLayoutsByTabId: {
'tab-a': { ...windowLayout, buffersByLeafId: { [leaf(1)]: 'scrollback' } }
}
},
'local'
)
const layout = store.getWorkspaceSession('local').terminalLayoutsByTabId['tab-a']
expect(layout?.root).toEqual(mainSession().terminalLayoutsByTabId['tab-a']!.root)
// The Store moves a saved buffer into a scrollback snapshot.
expect(Object.keys(layout?.scrollbackRefsByLeafId ?? {})).toEqual([leaf(1)])
expect(terminalTabs(store.getWorkspaceSession('local'))).toEqual(['tab-a', 'tab-b'])
})
it('passes a scalar patch through', async () => {
store.setWorkspaceSession(mainSession(), 'local')
await call('session:patch', { activeWorktreeId: OTHER_KEY }, 'local')
expect(store.getWorkspaceSession('local').activeWorktreeId).toBe(OTHER_KEY)
expect(terminalTabs(store.getWorkspaceSession('local'))).toEqual(['tab-a', 'tab-b'])
})
it('gives a visited workspace main holds its terminal row, and one main lacks none', async () => {
store.setWorkspaceSession(
addWorkspace(emptySession(), GIT_KEY, [
{ id: 'group-1', tabs: [{ id: 'editor-1', kind: 'editor' }] }
]),
'local'
)
const save = {
...addWorkspace(emptySession(), GIT_KEY, [
{ id: 'group-1', tabs: [{ id: 'editor-1', kind: 'editor' }] }
]),
tabsByWorktree: { [GIT_KEY]: [], [OTHER_KEY]: [] },
lastVisitedAtByWorktreeId: { [GIT_KEY]: 5, [OTHER_KEY]: 6 }
}
await call('session:set', save, 'local')
const saved = store.getWorkspaceSession('local')
expect(Object.hasOwn(saved.tabsByWorktree, GIT_KEY)).toBe(true)
expect(Object.hasOwn(saved.tabsByWorktree, OTHER_KEY)).toBe(false)
})
it('writes a paired server’s partition as the window sent it', async () => {
store.setWorkspaceSession(mainSession(), 'runtime:env-1')
await call('session:set', laggingWindowSave(), 'runtime:env-1')
expect(terminalTabs(store.getWorkspaceSession('runtime:env-1'))).toEqual(['tab-a', 'tab-new'])
})
})
+20 -36
View File
@@ -4,8 +4,8 @@ import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { parseTerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target'
import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source'
import { markAgentLaunchesClosedByUser } from '../agent-launch/agent-launch-pane-attachment'
import { windowSaveForMain } from '../persistence/window-save-pane-trees'
import { writeWindowSaveWatchingLayout } from '../persistence/window-save-divergence'
import { windowViewPatchForMain, windowViewSaveForMain } from '../persistence/window-save-view'
import { reportWindowSaveDrift } from '../persistence/window-save-divergence'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import type {
WorkspaceSessionPatch,
@@ -31,49 +31,33 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic
return store.getWorkspaceSessionHostIds()
})
// Switch point: main keeps its own pane trees over the window's, and reports any layout the
// save still changes.
const saveFromWindow = (
hostId: string | null | undefined,
write: () => void,
source: string,
save: WorkspaceSessionState | WorkspaceSessionPatch
) =>
ORCA_LAYOUT_OWNER_IS_MAIN
? writeWindowSaveWatchingLayout(
() => store.getWorkspaceSession(hostId),
hostId,
write,
source,
save.terminalLayoutsByTabId
)
: write()
const forMain = <T extends WorkspaceSessionState | WorkspaceSessionPatch>(
save: T,
hostId: string | null | undefined
): T =>
ORCA_LAYOUT_OWNER_IS_MAIN
? windowSaveForMain(store.getWorkspaceSession(hostId), save, hostId)
: save
// Switch point: main keeps its own layout over the window's and takes the window's view and
// facts; the drift check reports where the save's layout still differs from main's.
const sessionForMain = (args: WorkspaceSessionState, hostId: string | null | undefined) => {
const stored = store.getWorkspaceSession(hostId)
reportWindowSaveDrift(stored, args, hostId, 'session:set')
return windowViewSaveForMain(stored, args, hostId)
}
const patchForMain = (args: WorkspaceSessionPatch, hostId: string | null | undefined) => {
const stored = store.getWorkspaceSession(hostId)
reportWindowSaveDrift(stored, { ...stored, ...args }, hostId, 'session:patch')
return windowViewPatchForMain(stored, args, hostId)
}
ipcMain.handle('session:set', (_event, args: WorkspaceSessionState, hostId?: string | null) => {
if (!isFenced(hostId)) {
saveFromWindow(
hostId,
() => store.setWorkspaceSession(forMain(args, hostId), hostId),
'session:set',
args
store.setWorkspaceSession(
ORCA_LAYOUT_OWNER_IS_MAIN ? sessionForMain(args, hostId) : args,
hostId
)
}
})
ipcMain.handle('session:patch', (_event, args: WorkspaceSessionPatch, hostId?: string | null) => {
if (!isFenced(hostId)) {
saveFromWindow(
hostId,
() => store.patchWorkspaceSession(forMain(args, hostId), hostId),
'session:patch',
args
store.patchWorkspaceSession(
ORCA_LAYOUT_OWNER_IS_MAIN ? patchForMain(args, hostId) : args,
hostId
)
}
})
@@ -21,6 +21,7 @@ import {
} from './local-worktree-scan-generation'
import { dropWorkspacesTheListingInvalidates } from './persistence/restoring-sessions/invalid-workspace-layout-purge'
import { WorkspaceLayoutStream } from './runtime/workspace-layout-stream'
import { windowViewSaveForMain } from './persistence/window-save-view'
vi.mock('../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
vi.mock('./ssh/ssh-config-parser', () => ({
@@ -222,8 +223,7 @@ describe('main drops the layout of workspaces the catalog no longer validates',
})
})
// Pending until Core-A: today a window save still carries structure, so it can restore the key.
it.skip('a later window save that still names a dropped workspace does not bring it back', () => {
it('a later window save that still names a dropped workspace does not bring it back', () => {
const store = (() => {
writeDataFile({
schemaVersion: 1,
@@ -235,7 +235,14 @@ describe('main drops the layout of workspaces the catalog no longer validates',
})()
dropWorkspacesTheListingInvalidates(store, listing([KEPT]))
store.setWorkspaceSession(sessionWith(KEPT, DELETED), 'local')
store.setWorkspaceSession(
windowViewSaveForMain(
store.getWorkspaceSession('local'),
sessionWith(KEPT, DELETED),
'local'
),
'local'
)
expect(storedKeys(store)).toEqual([KEPT])
})
@@ -1,7 +1,7 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import {
addWorkspace,
@@ -9,8 +9,7 @@ import {
GIT_KEY,
leaf
} from '../../shared/workspace-layout/workspace-layout-session.test-fixture'
import { writeWindowSaveWatchingLayout } from './window-save-divergence'
import { windowSaveForMain } from './window-save-pane-trees'
import { reportWindowSaveDrift } from './window-save-divergence'
function session(tabs: { id: string; title?: string }[]): WorkspaceSessionState {
return addWorkspace(emptySession(), GIT_KEY, [
@@ -24,24 +23,21 @@ function session(tabs: { id: string; title?: string }[]): WorkspaceSessionState
])
}
/** Runs a save that replaces the stored session with `next`, as the store would. */
/** Checks a window save of `next` against main's `stored` partition. */
function save(stored: WorkspaceSessionState, next: WorkspaceSessionState, hostId = 'local') {
let current = stored
writeWindowSaveWatchingLayout(
() => current,
hostId,
() => {
current = next
},
'session:set'
)
return current
reportWindowSaveDrift(stored, next, hostId, 'session:set')
return next
}
describe('writeWindowSaveWatchingLayout', () => {
describe('reportWindowSaveDrift', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
let logDir: string | null = null
beforeEach(() => {
logDir = mkdtempSync(join(tmpdir(), 'orca-divergence-'))
vi.stubEnv('ORCA_LAYOUT_SAVE_DIVERGENCE_LOG', join(logDir, 'divergence.jsonl'))
})
afterEach(() => {
warn.mockClear()
vi.unstubAllEnvs()
@@ -58,10 +54,8 @@ describe('writeWindowSaveWatchingLayout', () => {
expect(warn).not.toHaveBeenCalled()
})
it('reports a tab the save added, dropped, renamed or moved, and still writes it', () => {
logDir = mkdtempSync(join(tmpdir(), 'orca-divergence-'))
const logPath = join(logDir, 'divergence.jsonl')
vi.stubEnv('ORCA_LAYOUT_SAVE_DIVERGENCE_LOG', logPath)
it('reports a tab the save added, dropped, renamed or moved', () => {
const logPath = join(logDir!, 'divergence.jsonl')
const next = session([{ id: 'tab-b', title: 'renamed' }, { id: 'tab-c' }])
expect(save(session([{ id: 'tab-a' }, { id: 'tab-b' }]), next)).toBe(next)
@@ -91,7 +85,7 @@ describe('writeWindowSaveWatchingLayout', () => {
)
})
it('still writes the save when its log cannot be written', () => {
it('never throws when its log cannot be written', () => {
vi.stubEnv(
'ORCA_LAYOUT_SAVE_DIVERGENCE_LOG',
join(tmpdir(), 'orca-missing-dir', 'x', 'log.jsonl')
@@ -122,33 +116,21 @@ describe('writeWindowSaveWatchingLayout', () => {
)
})
it('writes once when the check cannot read the stored copy, and lets a failed write throw', () => {
const write = vi.fn()
writeWindowSaveWatchingLayout(
() => {
throw new Error('unreadable')
},
'local',
write,
'session:set'
)
expect(write).toHaveBeenCalledOnce()
it('checks nothing without its log, and never throws when the check fails', () => {
vi.stubEnv('ORCA_LAYOUT_SAVE_DIVERGENCE_LOG', '')
save(session([{ id: 'tab-a' }]), session([{ id: 'tab-c' }]))
expect(warn).not.toHaveBeenCalled()
const read = vi.fn(() => session([{ id: 'tab-a' }]))
expect(() =>
writeWindowSaveWatchingLayout(
read,
'local',
() => {
throw new Error('disk full')
},
'session:set'
)
).toThrow('disk full')
expect(read).toHaveBeenCalledOnce()
vi.stubEnv('ORCA_LAYOUT_SAVE_DIVERGENCE_LOG', join(logDir!, 'divergence.jsonl'))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a malformed stored copy is the case under test.
expect(() => save(null as never, session([{ id: 'tab-a' }]))).not.toThrow()
expect(warn).toHaveBeenCalledWith(
'[layout-owner] save divergence check failed:',
expect.anything()
)
})
it('counts panes only rebound to their terminals after the layout changes', () => {
it('leaves out panes only rebound to their terminals', () => {
const bound = (ptyId: string): WorkspaceSessionState => {
const result = session([{ id: 'tab-a' }, { id: 'tab-b' }])
result.terminalLayoutsByTabId['tab-a'] = {
@@ -165,45 +147,30 @@ describe('writeWindowSaveWatchingLayout', () => {
expect(warn).toHaveBeenCalledWith(
'[layout-owner] window save changed main layout:',
'session:set',
expect.objectContaining({ paths: ['group group-1 changed: tabOrder', '1 pane(s) rebound'] })
expect.objectContaining({ paths: ['group group-1 changed: tabOrder'] })
)
})
it('counts a pane’s first terminal binding as a rebind, not a layout change', () => {
// Main writes these itself (spawn, its default-tabs and close commands, D1); a save lagging them asks for nothing.
it('stays quiet for records main keeps through its own writes', () => {
const key = 'repo-1::/Users/dev/notes'
const stored = session([{ id: 'tab-a' }])
const next = structuredClone(stored)
next.terminalLayoutsByTabId['tab-a'] = {
...next.terminalLayoutsByTabId['tab-a']!,
const bound = structuredClone(stored)
bound.terminalLayoutsByTabId['tab-a'] = {
...bound.terminalLayoutsByTabId['tab-a']!,
ptyIdsByLeafId: { [leaf(1)]: 'pty-1' }
}
save(stored, next)
expect(warn).toHaveBeenCalledWith(
'[layout-owner] window save changed main layout:',
'session:set',
expect.objectContaining({ paths: ['1 pane(s) rebound'] })
)
})
it('reports a default-tab record of a workspace with no tabs', () => {
const stored = emptySession()
const next = {
save(stored, bound)
save(emptySession(), {
...emptySession(),
defaultTerminalTabsAppliedByWorktreeId: { 'repo-1::/Users/dev/notes': true as const }
}
defaultTerminalTabsAppliedByWorktreeId: { [key]: true },
tabsByWorktree: { [key]: [] },
closedTerminalTabTombstonesByTabId: { 'tab-x': { worktreeId: key, closedAt: 1 } },
unifiedTabs: { [key]: [] }
})
save(stored, next)
expect(warn).toHaveBeenCalledOnce()
expect(warn).toHaveBeenCalledWith(
'[layout-owner] window save changed main layout:',
'session:set',
expect.objectContaining({
workspace: 'repo-1::/Users/dev/notes',
paths: ['defaultTabsAppliedByWorkspace changed']
})
)
expect(warn).not.toHaveBeenCalled()
})
it('stays quiet for a save that only names a workspace with nothing in it', () => {
@@ -216,61 +183,6 @@ describe('writeWindowSaveWatchingLayout', () => {
expect(warn).not.toHaveBeenCalled()
})
it('reports an empty terminal row a save adds or drops: a relaunch reads it there', () => {
const key = 'repo-1::/Users/dev/emptied'
const emptied = { ...emptySession(), tabsByWorktree: { [key]: [] } }
save(emptySession(), emptied)
save(emptied, emptySession())
expect(warn).toHaveBeenCalledTimes(2)
expect(warn).toHaveBeenCalledWith(
'[layout-owner] window save changed main layout:',
'session:set',
expect.objectContaining({ workspace: key, paths: ['terminal row owner changed'] })
)
})
it('reports a closed tab record only when the save adds or drops it, not when a key places it', () => {
const key = 'repo-1::/Users/dev/empty'
const closed = {
closedTerminalTabTombstonesByTabId: { 'tab-x': { worktreeId: key, closedAt: 1 } }
}
const stored = { ...emptySession(), ...closed }
const named = { ...stored, unifiedTabs: { [key]: [] } }
save(stored, named)
expect(warn).not.toHaveBeenCalled()
save(named, { ...named, closedTerminalTabTombstonesByTabId: {} })
save({ ...emptySession(), unifiedTabs: {} }, named)
for (const path of ['closed tab tab-x removed', 'closed tab tab-x added']) {
expect(warn).toHaveBeenCalledWith(
'[layout-owner] window save changed main layout:',
'session:set',
expect.objectContaining({ workspace: key, paths: [path] })
)
}
expect(warn).toHaveBeenCalledTimes(2)
})
it('reports a closed tab record the save moves into a workspace only it names', () => {
const from = 'repo-1::/Users/dev/old'
const to = 'repo-1::/Users/dev/new'
const closedIn = (key: string) => ({
...emptySession(),
closedTerminalTabTombstonesByTabId: { 'tab-x': { worktreeId: key, closedAt: 1 } }
})
save(closedIn(from), { ...closedIn(to), unifiedTabs: { [to]: [] } })
expect(warn).toHaveBeenCalledWith(
'[layout-owner] window save changed main layout:',
'session:set',
expect.objectContaining({ workspace: to, paths: ['closed tab tab-x added'] })
)
})
it('still reports a workspace with tabs that the save adds or drops', () => {
save(emptySession(), session([{ id: 'tab-a' }]))
save(session([{ id: 'tab-a' }]), emptySession())
@@ -311,11 +223,11 @@ describe('writeWindowSaveWatchingLayout', () => {
expect(warn).not.toHaveBeenCalled()
})
it('reports a pane tree main keeps over the save’s, and still writes the save', () => {
it('reports a save whose pane tree differs from main’s', () => {
const stored = session([{ id: 'tab-a' }])
const incoming = structuredClone(stored)
incoming.terminalLayoutsByTabId['tab-a'] = {
...incoming.terminalLayoutsByTabId['tab-a']!,
const next = structuredClone(stored)
next.terminalLayoutsByTabId['tab-a'] = {
...next.terminalLayoutsByTabId['tab-a']!,
root: {
type: 'split',
direction: 'vertical',
@@ -324,89 +236,19 @@ describe('writeWindowSaveWatchingLayout', () => {
second: { type: 'leaf', leafId: leaf(2) }
}
}
let current = stored
writeWindowSaveWatchingLayout(
() => current,
'local',
() => {
current = windowSaveForMain(current, incoming, 'local')
},
'session:set',
incoming.terminalLayoutsByTabId
)
expect(current.terminalLayoutsByTabId['tab-a']?.root).toEqual(
stored.terminalLayoutsByTabId['tab-a']?.root
)
expect(warn).toHaveBeenCalledOnce()
expect(warn).toHaveBeenCalledWith(
'[layout-owner] window save changed main layout:',
'session:set',
expect.objectContaining({
workspace: GIT_KEY,
paths: ['pane tree tab-a kept over the save’s']
})
)
})
it('reports a pane tree main keeps for a tab no workspace places', () => {
const stored = session([{ id: 'tab-a' }])
const layout = stored.terminalLayoutsByTabId['tab-a']!
stored.terminalLayoutsByTabId.orphan = { ...layout, root: { type: 'leaf', leafId: leaf(7) } }
const saved = { orphan: { ...layout, root: { type: 'leaf' as const, leafId: leaf(8) } } }
writeWindowSaveWatchingLayout(
() => stored,
'local',
() => {},
'session:set',
saved
)
expect(warn).toHaveBeenCalledWith(
'[layout-owner] window save changed main layout:',
'session:set',
expect.objectContaining({
workspace: '(no workspace)',
paths: ['pane tree orphan kept over the save’s']
})
)
})
it('reports a save that sends no pane tree for a tab main keeps one for', () => {
const stored = session([{ id: 'tab-a' }])
const saved = { 'tab-a': { ...stored.terminalLayoutsByTabId['tab-a']!, root: null } }
writeWindowSaveWatchingLayout(
() => stored,
'local',
() => {},
'session:set',
saved
)
save(stored, next)
expect(warn).toHaveBeenCalledWith(
'[layout-owner] window save changed main layout:',
'session:set',
expect.objectContaining({
workspace: GIT_KEY,
paths: ['pane tree tab-a kept over the save’s']
paths: expect.arrayContaining(['tab tab-a changed: panes'])
})
)
})
it('stays quiet for a save whose pane trees match main’s', () => {
const stored = session([{ id: 'tab-a' }])
const incoming = structuredClone(stored)
writeWindowSaveWatchingLayout(
() => stored,
'local',
() => {},
'session:set',
incoming.terminalLayoutsByTabId
)
expect(warn).not.toHaveBeenCalled()
})
it('leaves a paired server’s partition alone: its layout is the server’s', () => {
const next = session([{ id: 'tab-c' }])
expect(save(session([{ id: 'tab-a' }]), next, 'runtime:env-1')).toBe(next)
+44 -114
View File
@@ -1,6 +1,9 @@
// A layout-owner build's check that the window's session saves no longer carry layout: every tab
// change should reach main as a command, so a save that changes main's layout model is a change
// main learned only from the save. Dark: reports only, never alters or fails the write.
// A layout-owner build's drift check: every tab change reaches main as a command and main keeps
// its own layout over a window save, so a save whose layout differs from main's model shows a
// change main never got as a command. Report-only, and only when its log is set (evidence runs);
// it never alters, fails or delays the write. Records main keeps through its own writes (pane
// bindings, default-tab marks, closed-tab records, terminal row owners) are not compared: a save
// that has not caught up with them asks main for nothing. Deleted with the old path (phase F).
import { appendFileSync } from 'node:fs'
import {
@@ -18,25 +21,16 @@ import type {
} from '../../shared/workspace-layout/workspace-layout-model'
import type { TabGroupLayoutNode } from '../../shared/tab-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import {
closedTabsOfOneSidedWorkspaces,
droppedTabBarEntries,
overriddenPaneTrees,
terminalTabBarEntries,
type SavedPaneTrees
} from './window-save-raw-session-divergence'
import { droppedTabBarEntries, terminalTabBarEntries } from './window-save-raw-session-divergence'
/** Set by evidence runs: each divergence is appended there as one JSON line. */
const LOG_PATH_ENV = 'ORCA_LAYOUT_SAVE_DIVERGENCE_LOG'
const MAX_PATHS = 12
const RECORDS = ['defaultTabsAppliedByWorkspace', 'clientHostedBrowserPagesByWorkspace'] as const
/** A pane's terminal binding: it still reaches main only through saves, on every spawn. */
const BINDING_FIELDS = new Set(['ptyId', 'incarnationId'])
/** A pane's terminal binding and resume record: main writes them on every spawn and capture. */
const BINDING_FIELDS = new Set(['ptyId', 'incarnationId', 'sleeping'])
export type WindowSaveDivergence = { workspace: string; paths: string[] }
type WorkspaceDiff = { paths: string[]; rebound: number }
/** Main's layout model of one partition, as the session holds it. */
export function layoutModelOf(
session: WorkspaceSessionState | null | undefined,
@@ -99,14 +93,13 @@ function bindingOnly(leaf: unknown): boolean {
)
}
/** Panes whose only change is their terminal binding, first bind and unbind included. */
function reboundLeaves(
/** Each copy's panes without those whose only change is their binding, first or last included. */
function unboundLeaves(
before: Readonly<Record<string, unknown>> | undefined,
after: Readonly<Record<string, unknown>> | undefined
): { rebound: Set<string>; was: Map<string, unknown>; now: Map<string, unknown> } {
): { was: Map<string, unknown>; now: Map<string, unknown> } {
const was = recordById(before)
const now = recordById(after)
const rebound = new Set<string>()
for (const id of new Set([...was.keys(), ...now.keys()])) {
const old = was.get(id)
const next = now.get(id)
@@ -116,12 +109,11 @@ function reboundLeaves(
? changed.length > 0 && changed.every((field) => BINDING_FIELDS.has(field))
: bindingOnly(old ?? next)
) {
rebound.add(id)
was.delete(id)
now.delete(id)
}
}
return { rebound, was, now }
return { was, now }
}
/**
@@ -140,14 +132,6 @@ function hasContent(workspace: WorkspaceLayout | undefined): workspace is Worksp
)
}
function diffClosedTabs(before: WorkspaceLayout, after: WorkspaceLayout): string[] {
return diffKeyed(
'closed tab',
recordById(before.closedTerminalTabs),
recordById(after.closedTerminalTabs)
)
}
/** A lone group with no stored tree is that group's leaf, as main's own save writes it. */
function groupTreeOf(workspace: WorkspaceLayout): TabGroupLayoutNode | null {
const [only, ...rest] = workspace.groups
@@ -156,19 +140,17 @@ function groupTreeOf(workspace: WorkspaceLayout): TabGroupLayoutNode | null {
)
}
/** What a save changed in one workspace of main's model; empty paths and no rebound when nothing. */
export function diffWorkspaceLayouts(
/** Where one workspace of two copies of main's model differs; empty when nothing. */
function diffWorkspaceLayouts(
before: WorkspaceLayout | undefined,
after: WorkspaceLayout | undefined
): WorkspaceDiff {
): string[] {
if (!hasContent(before) || !hasContent(after)) {
if (hasContent(before) || hasContent(after)) {
return { paths: [hasContent(after) ? 'workspace added' : 'workspace removed'], rebound: 0 }
}
// Both hold no tab: only a closed-tab record of a workspace both copies name can differ.
return { paths: before && after ? diffClosedTabs(before, after) : [], rebound: 0 }
return hasContent(before) || hasContent(after)
? [hasContent(after) ? 'workspace added' : 'workspace removed']
: []
}
const leaves = reboundLeaves(before.leaves, after.leaves)
const leaves = unboundLeaves(before.leaves, after.leaves)
const paths = [
...diffKeyed(
'group',
@@ -191,13 +173,12 @@ export function diffWorkspaceLayouts(
listById(before.browserTabs, (b) => b.id),
listById(after.browserTabs, (b) => b.id)
),
...diffClosedTabs(before, after),
...diffKeyed('leaf', leaves.was, leaves.now)
]
if (stableJson(groupTreeOf(before)) !== stableJson(groupTreeOf(after))) {
paths.push('group layout changed')
}
return { paths, rebound: leaves.rebound.size }
return paths
}
/**
@@ -209,46 +190,19 @@ export function windowSaveDivergences(
now: WorkspaceLayoutModel | null,
rawPaths: ReadonlyMap<string, string[]> = new Map()
): WindowSaveDivergence[] {
// Records count for a workspace with no tabs too.
const workspaces = new Set([
...[was, now].flatMap((model) =>
model
? [
...Object.keys(model.workspaces),
...RECORDS.flatMap((record) => Object.keys(model.records[record] ?? {}))
]
: []
),
...[was, now].flatMap((model) => (model ? Object.keys(model.workspaces) : [])),
...rawPaths.keys()
])
const result: WindowSaveDivergence[] = []
// Ignored, as main's own writes: pane bindings, resume records, default-tab marks, closed-tab records, row owners.
for (const workspace of workspaces) {
const before = was?.workspaces[workspace]
const after = now?.workspaces[workspace]
const { paths: modelPaths, rebound } = diffWorkspaceLayouts(before, after)
const paths = [...modelPaths, ...(rawPaths.get(workspace) ?? [])]
for (const record of RECORDS) {
if (
stableJson(was?.records[record]?.[workspace] ?? null) !==
stableJson(now?.records[record]?.[workspace] ?? null)
) {
paths.push(`${record} changed`)
}
}
// Counted for an empty workspace too: a relaunch reads it there to skip the first terminal.
if (
Boolean(was?.legacy.terminalRowOwners[workspace]) !==
Boolean(now?.legacy.terminalRowOwners[workspace])
) {
paths.push('terminal row owner changed')
}
// The binding count goes last and is never cut, so it crowds out no structural change.
const shown =
rebound > 0
? [...paths.slice(0, MAX_PATHS - 1), `${rebound} pane(s) rebound`]
: paths.slice(0, MAX_PATHS)
if (shown.length > 0) {
result.push({ workspace, paths: shown })
const paths = [
...diffWorkspaceLayouts(was?.workspaces[workspace], now?.workspaces[workspace]),
...(rawPaths.get(workspace) ?? [])
]
if (paths.length > 0) {
result.push({ workspace, paths: paths.slice(0, MAX_PATHS) })
}
}
return result
@@ -271,53 +225,29 @@ function report(source: string, partition: ExecutionHostId, divergence: WindowSa
}
/**
* Runs the window's save `write` and reports what it changed in main's layout model, and which of
* its `savedPaneTrees` (as the window sent them) main keeps its own tree over. A paired server's
* partition is skipped: its layout is the server's, mirrored by the window. Nothing here can fail
* the save: the write's own errors propagate, the check's are only logged.
* Reports where the window's `save` (a whole session, or a patch laid over `stored`) differs from
* main's layout model of `stored`. A paired server's partition is skipped: its layout is the
* server's, mirrored by the window. Nothing here can fail the save: errors are only logged.
*/
export function writeWindowSaveWatchingLayout(
read: () => WorkspaceSessionState | null | undefined,
export function reportWindowSaveDrift(
stored: WorkspaceSessionState,
save: WorkspaceSessionState,
hostId: string | null | undefined,
write: () => void,
source: string,
savedPaneTrees?: SavedPaneTrees
source: string
): void {
if (!process.env[LOG_PATH_ENV]) {
return
}
const partition = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID
if (parseExecutionHostId(partition)?.kind === 'runtime') {
write()
return
}
let was: WorkspaceLayoutModel | null | undefined
let wasEntries: Map<string, Set<string>> | undefined
let wasClosed: WorkspaceSessionState['closedTerminalTabTombstonesByTabId']
const rawPaths = new Map<string, string[]>()
try {
const stored = read()
was = layoutModelOf(stored, partition)
wasEntries = stored ? terminalTabBarEntries(stored) : new Map()
wasClosed = stored?.closedTerminalTabTombstonesByTabId
if (savedPaneTrees) {
overriddenPaneTrees(stored, was, savedPaneTrees, rawPaths)
}
} catch (error) {
was = undefined
console.warn('[layout-owner] save divergence check failed:', error)
}
write()
if (was === undefined || wasEntries === undefined) {
return
}
try {
const now = read()
const nowModel = layoutModelOf(now, partition)
droppedTabBarEntries(wasEntries, now, rawPaths)
closedTabsOfOneSidedWorkspaces(
{ model: was, closed: wasClosed },
{ model: nowModel, closed: now?.closedTerminalTabTombstonesByTabId },
rawPaths
)
for (const divergence of windowSaveDivergences(was, nowModel, rawPaths)) {
const was = layoutModelOf(stored, partition)
const now = layoutModelOf(save, partition)
const rawPaths = new Map<string, string[]>()
droppedTabBarEntries(terminalTabBarEntries(stored), save, rawPaths)
for (const divergence of windowSaveDivergences(was, now, rawPaths)) {
report(source, partition, divergence)
}
} catch (error) {
@@ -1,196 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { normalizeTerminalLayoutPtyOwnership } from '../../shared/terminal-layout-pty-ownership'
import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { loadWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-load'
import { nameBasedLoadContext } from '../../shared/workspace-layout/workspace-layout-minted-ids'
import {
addWorkspace,
emptySession,
GIT_KEY,
leaf
} from '../../shared/workspace-layout/workspace-layout-session.test-fixture'
import { windowSaveForMain } from './window-save-pane-trees'
import { applyPtyBinding } from './loading-store/pty-binding-session-update'
vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
const A = '11111111-1111-4111-8111-111111111111'
const B = '22222222-2222-4222-8222-222222222222'
const mains: TerminalLayoutSnapshot = {
root: {
type: 'split',
direction: 'vertical',
ratio: 0.3,
first: { type: 'leaf', leafId: A },
second: { type: 'leaf', leafId: B }
},
activeLeafId: A,
expandedLeafId: null
}
function stored(layouts: Record<string, TerminalLayoutSnapshot>): WorkspaceSessionState {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: windowSaveForMain reads only terminalLayoutsByTabId.
return { terminalLayoutsByTabId: layouts } as WorkspaceSessionState
}
describe('windowSaveForMain', () => {
it('keeps main’s pane tree, and the window’s focus, zoom and terminals', () => {
const windows: TerminalLayoutSnapshot = {
root: { type: 'leaf', leafId: A },
activeLeafId: B,
expandedLeafId: B,
ptyIdsByLeafId: { [A]: 'pty-a' }
}
const save = windowSaveForMain(
stored({ 'tab-1': mains }),
{ terminalLayoutsByTabId: { 'tab-1': windows } },
'local'
)
expect(save.terminalLayoutsByTabId?.['tab-1']).toEqual({ ...windows, root: mains.root })
})
it('keeps the window’s tree for a tab main holds no tree of', () => {
const windows: TerminalLayoutSnapshot = {
root: { type: 'leaf', leafId: A },
activeLeafId: A,
expandedLeafId: null
}
const save = { terminalLayoutsByTabId: { 'tab-new': windows } }
expect(windowSaveForMain(stored({ 'tab-1': mains }), save, 'local')).toBe(save)
})
it('passes a save with no pane layouts, or matching trees, through unchanged', () => {
const noLayouts = { activeWorktreeId: 'wt-1' }
expect(windowSaveForMain(stored({ 'tab-1': mains }), noLayouts, 'local')).toBe(noLayouts)
const same = { terminalLayoutsByTabId: { 'tab-1': { ...mains, activeLeafId: B } } }
expect(windowSaveForMain(stored({ 'tab-1': mains }), same, 'local')).toBe(same)
})
it('keeps the window’s trees in a paired server’s partition, where main applies no pane command', () => {
const save = {
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf' as const, leafId: A },
activeLeafId: A,
expandedLeafId: null
}
}
}
expect(windowSaveForMain(stored({ 'tab-1': mains }), save, 'runtime:env-1')).toBe(save)
expect(windowSaveForMain(stored({ 'tab-1': mains }), save, 'ssh:target-1')).not.toBe(save)
})
})
describe('pane trees main writes itself', () => {
it('a Codex restart of a rootless pane: main wrote the one-pane tree the window then writes', () => {
const session = {
activeRepoId: null,
activeWorktreeId: 'wt-1',
activeTabId: 'tab-1',
tabsByWorktree: {
'wt-1': [
{
id: 'tab-1',
ptyId: 'pty-old',
worktreeId: 'wt-1',
title: 'codex',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
'tab-1': {
root: null,
activeLeafId: A,
expandedLeafId: null,
ptyIdsByLeafId: { [A]: 'pty-old' }
}
}
} satisfies WorkspaceSessionState
// The replacement's spawn binds before it replies (codex-detached-pane-restart.ts).
applyPtyBinding(
{
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: A,
ptyId: 'pty-new',
placement: { kind: 'root' }
},
session,
'wt-1',
`tab-1:${A}`
)
// The window's rebind: singlePaneLayoutSnapshot(A, 'pty-new').
const windows: TerminalLayoutSnapshot = {
root: { type: 'leaf', leafId: A },
activeLeafId: A,
expandedLeafId: null,
ptyIdsByLeafId: { [A]: 'pty-new' }
}
const save = windowSaveForMain(
session,
{ terminalLayoutsByTabId: { 'tab-1': windows } },
'local'
)
expect(session.terminalLayoutsByTabId['tab-1']?.root).toEqual(windows.root)
expect(save.terminalLayoutsByTabId?.['tab-1']).toEqual(windows)
})
})
describe('windowSaveForMain after a duplicate-terminal repair', () => {
it('stores the repaired pane tree, so the next load still draws one pane', () => {
const session = addWorkspace(emptySession(), GIT_KEY, [
{
id: 'g1',
tabs: [
{
id: 'tab-a',
leaves: [
[leaf(1), 'pty-a'],
[leaf(2), 'pty-a']
]
}
]
}
])
const paneRoot = (state: WorkspaceSessionState) => {
const workspace = loadWorkspaceLayout(LOCAL_EXECUTION_HOST_ID, state, nameBasedLoadContext())
.layout.workspaces[GIT_KEY]!
const tab = workspace.tabs.find((candidate) => candidate.id === 'tab-a')
return {
root: tab?.kind === 'terminal' ? tab.panes.root : undefined,
ptyId: workspace.leaves?.[leaf(1)]?.ptyId
}
}
const repaired = { root: { type: 'leaf', leafId: leaf(1) }, ptyId: 'pty-a' }
expect(paneRoot(session)).toEqual(repaired)
// The window saves its own repair of the same stored layout.
const windowLayout = normalizeTerminalLayoutPtyOwnership(
session.terminalLayoutsByTabId['tab-a']!
).snapshot
const save = windowSaveForMain(
session,
{ terminalLayoutsByTabId: { 'tab-a': windowLayout } },
'local'
)
const next = { ...session, terminalLayoutsByTabId: save.terminalLayoutsByTabId! }
expect(next.terminalLayoutsByTabId['tab-a']).toEqual(windowLayout)
expect(paneRoot(next)).toEqual(repaired)
})
})
@@ -1,45 +0,0 @@
// A window's session save in a layout-owner build: every pane change reaches main as a layout
// command, so the save's pane trees (which panes a tab has, their shape and sizes) are dropped for
// tabs main holds, and main keeps its own. The rest of each pane layout stays the window's: which
// pane is focused or zoomed, its terminals and scrollback. A tab main does not hold yet keeps the
// window's tree, as does a paired server's partition: main applies no pane command there.
import { parseExecutionHostId } from '../../shared/execution-host'
import { normalizeTerminalLayoutPtyOwnership } from '../../shared/terminal-layout-pty-ownership'
import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types'
import type {
WorkspaceSessionPatch,
WorkspaceSessionState
} from '../../shared/workspace-session-state-types'
function withStoredPaneTrees(
stored: Readonly<Record<string, TerminalLayoutSnapshot>> | undefined,
incoming: Record<string, TerminalLayoutSnapshot>
): Record<string, TerminalLayoutSnapshot> {
let result = incoming
for (const [tabId, layout] of Object.entries(incoming)) {
const storedLayout = stored?.[tabId]
// Main's Loader drops a duplicate terminal's pane; storing that tree makes the repair durable.
const root = storedLayout && normalizeTerminalLayoutPtyOwnership(storedLayout).snapshot.root
if (root && layout.root !== root) {
result = result === incoming ? { ...incoming } : result
result[tabId] = { ...layout, root }
}
}
return result
}
/** The save main applies, in a layout-owner build (the IPC entries call it behind the switch). */
export function windowSaveForMain<T extends WorkspaceSessionState | WorkspaceSessionPatch>(
stored: WorkspaceSessionState,
save: T,
hostId: string | null | undefined
): T {
if (!save.terminalLayoutsByTabId || parseExecutionHostId(hostId)?.kind === 'runtime') {
return save
}
const layouts = withStoredPaneTrees(stored.terminalLayoutsByTabId, save.terminalLayoutsByTabId)
return layouts === save.terminalLayoutsByTabId
? save
: { ...save, terminalLayoutsByTabId: layouts }
}
@@ -1,18 +1,9 @@
// What a window's save changes that main's layout model cannot show, read from the stored session
// itself: the Loader rebuilds a dropped terminal tab-bar entry from its row, places closed-tab
// records into whatever workspace a key names, and main keeps its own pane trees over the save's.
// Each finding is added to `paths` under its workspace.
// Where a window's save differs from main in a way main's layout model cannot show, read from the
// sessions themselves: the Loader rebuilds a dropped terminal tab-bar entry from its row. Each
// finding is added to `paths` under its workspace.
import { stableJson } from '../../shared/workspace-layout/workspace-layout-load-report'
import type { WorkspaceLayoutModel } from '../../shared/workspace-layout/workspace-layout-model'
import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
/** The report key of a pane tree whose tab no workspace names. */
const UNPLACED = '(no workspace)'
export type SavedPaneTrees = Readonly<Record<string, TerminalLayoutSnapshot>>
/** The terminal tabs (by entity id) each workspace's stored tab bar names. */
export function terminalTabBarEntries(session: WorkspaceSessionState): Map<string, Set<string>> {
return new Map(
@@ -53,58 +44,3 @@ export function droppedTabBarEntries(
}
}
}
/**
* The save's pane trees that differ from main's: main keeps its own for every tab it has one for
* (windowSaveForMain), so each is reported under the workspace whose tab or row names it.
*/
export function overriddenPaneTrees(
stored: WorkspaceSessionState | null | undefined,
model: WorkspaceLayoutModel | null,
saved: SavedPaneTrees,
paths: Map<string, string[]>
): void {
const homes = new Map<string, string>()
for (const [workspace, rows] of Object.entries(stored?.tabsByWorktree ?? {})) {
rows.forEach((row) => homes.set(row.id, workspace))
}
for (const [workspace, layout] of Object.entries(model?.workspaces ?? {})) {
layout.tabs.forEach((tab) => homes.set(tab.entityId, workspace))
}
for (const [tabId, layout] of Object.entries(saved)) {
const mains = stored?.terminalLayoutsByTabId?.[tabId]?.root
if (mains && stableJson(mains) !== stableJson(layout.root ?? null)) {
addPath(paths, homes.get(tabId) ?? UNPLACED, `pane tree ${tabId} kept over the save’s`)
}
}
}
type PartitionCopy = {
model: WorkspaceLayoutModel | null
closed: WorkspaceSessionState['closedTerminalTabTombstonesByTabId']
}
/**
* Closed-tab records of a workspace only one copy has: the model diff cannot tell a record the
* Loader just placed there (already stored, unplaced) from one the save added or dropped.
*/
export function closedTabsOfOneSidedWorkspaces(
was: PartitionCopy,
now: PartitionCopy,
paths: Map<string, string[]>
): void {
const oneSided = (from: PartitionCopy, other: PartitionCopy, change: string) => {
for (const [workspace, layout] of Object.entries(from.model?.workspaces ?? {})) {
if (other.model?.workspaces[workspace]) {
continue
}
for (const tabId of Object.keys(layout.closedTerminalTabs ?? {})) {
if (other.closed?.[tabId]?.worktreeId !== layout.worktreeId) {
addPath(paths, workspace, `closed tab ${tabId} ${change}`)
}
}
}
}
oneSided(now, was, 'added')
oneSided(was, now, 'removed')
}
+140
View File
@@ -0,0 +1,140 @@
// A window's session save in a layout-owner build: every tab and pane change reaches main as a
// layout command, so main keeps its own layout and takes from the save only what the window owns:
// its view (selection, focus, drafts), the facts its views report (titles, scrollback, page
// state) and the fields passed through untouched. A paired server's partition is the server's
// layout, mirrored by the window, and passes through as it is.
import {
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostId,
parseExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import type {
WorkspaceSessionPatch,
WorkspaceSessionState
} from '../../shared/workspace-session-state-types'
import { SESSION_FIELD_OWNERS } from '../../shared/workspace-layout/workspace-layout-beside'
import { loadWorkspaceLayoutWithoutReport } 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 type { WorkspaceLayoutModel } from '../../shared/workspace-layout/workspace-layout-model'
import { patchStoredSession } from '../../shared/workspace-layout/workspace-layout-record-patch'
import { saveWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-save'
const FIELD_OWNERS: Readonly<Record<string, string>> = SESSION_FIELD_OWNERS
/** Fields main writes from its own layout; the Loader's one-time legacy field goes with them. */
function isLayoutField(field: string): boolean {
const owner = Object.hasOwn(FIELD_OWNERS, field) ? FIELD_OWNERS[field] : undefined
return owner === 'layout' || owner === 'legacy'
}
/**
* A workspace main holds that the window visited keeps its terminal row, as the window's save
* did: a relaunch then leaves a workspace emptied by a recorded close empty
* (isTerminalWorkspaceEmptiedOnPurpose, admitsInitialSurface).
*/
function withVisitedRowOwners(
layout: WorkspaceLayoutModel,
visited: Readonly<Record<string, number>> | undefined
): WorkspaceLayoutModel {
const added = Object.keys(visited ?? {}).filter(
(key) => layout.workspaces[key] && !layout.legacy.terminalRowOwners[key]
)
if (added.length === 0) {
return layout
}
const owners = { ...layout.legacy.terminalRowOwners }
for (const key of added) {
owners[key] = true
}
return { ...layout, legacy: { ...layout.legacy, terminalRowOwners: owners } }
}
/** Main's layout with the window's view, facts and carried fields, written as a session. */
function joinWindowView(
main: LoadedWorkspaceLayout,
windows: LoadedWorkspaceLayout
): WorkspaceSessionState {
return saveWorkspaceLayout({
layout: withVisitedRowOwners(main.layout, windows.desktopView.lastVisitedAtByWorktreeId),
desktopView: windows.desktopView,
facts: windows.facts,
// What names no tab or workspace main holds is main's too.
carried: {
...windows.carried,
unownedTerminalLayouts: main.carried.unownedTerminalLayouts,
unplacedSleepingRecords: main.carried.unplacedSleepingRecords,
unplacedIncarnations: main.carried.unplacedIncarnations,
unplacedClosedTabs: main.carried.unplacedClosedTabs
}
})
}
/**
* The stored partition with the window's view and facts written into main's records. Only records
* the join changes are rewritten, as a layout command writes, so untouched records stay as stored.
*/
function storedWithWindowView(
partition: ExecutionHostId,
stored: WorkspaceSessionState,
window: WorkspaceSessionState
): WorkspaceSessionState {
const context = nameBasedLoadContext()
const main = loadWorkspaceLayoutWithoutReport(partition, stored, context)
const windows = loadWorkspaceLayoutWithoutReport(partition, window, context)
return patchStoredSession(stored, saveWorkspaceLayout(main), joinWindowView(main, windows))
}
/** The window's save with each named layout field taken from main's stored partition. */
function withMainLayoutFields<T extends WorkspaceSessionPatch>(
save: T,
joined: WorkspaceSessionState,
fields: readonly string[]
): T {
const mains: Readonly<Record<string, unknown>> = joined
const result: Record<string, unknown> = { ...save }
for (const field of fields) {
result[field] = mains[field]
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every field is the save's own or main's stored session's, the same session type.
return result as T
}
/**
* A full save main applies in a layout-owner build (`session:set`, the shutdown stage): the
* window's own fields as sent, and main's layout fields with the window's view and facts in them.
*/
export function windowViewSaveForMain(
stored: WorkspaceSessionState,
save: WorkspaceSessionState,
hostId: string | null | undefined
): WorkspaceSessionState {
const partition = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID
if (parseExecutionHostId(partition)?.kind === 'runtime') {
return save
}
const joined = storedWithWindowView(partition, stored, save)
const fields = [...new Set([...Object.keys(save), ...Object.keys(joined)])]
return withMainLayoutFields(save, joined, fields.filter(isLayoutField))
}
/**
* A `session:patch` in a layout-owner build: its own fields as sent, and each layout field it
* carries comes back as main's with the window's view and facts in it. A patch carrying none
* skips the layout entirely, as today's scalar patch skips terminal normalization.
*/
export function windowViewPatchForMain(
stored: WorkspaceSessionState,
patch: WorkspaceSessionPatch,
hostId: string | null | undefined
): WorkspaceSessionPatch {
const partition = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID
const fields = Object.keys(patch).filter(isLayoutField)
if (parseExecutionHostId(partition)?.kind === 'runtime' || fields.length === 0) {
return patch
}
const joined = storedWithWindowView(partition, stored, { ...stored, ...patch })
return withMainLayoutFields(patch, joined, fields)
}
+3
View File
@@ -1,4 +1,5 @@
import type { AppIdentity } from '../../shared/app-identity'
import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume'
import type { E2EConfig } from '../../shared/e2e-config'
import type { ExecutionHostId } from '../../shared/execution-host'
import type {
@@ -38,6 +39,8 @@ export type AppApi = {
stageBeforeUnloadSync: (args: {
sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[]
ui: Partial<PersistedUIState>
/** Layout-owner builds: the resume records the quit capture changed. */
sleepRecords?: SleepingAgentSessionRecord[]
}) => void
/** Resolves once the last staged checkpoint is durably written; rejects if that
* write failed, so a reload/restart can abort instead of losing the snapshot. */
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import {
createShutdownCheckpointPersist,
type ShutdownCheckpointPersistDeps
@@ -21,6 +22,30 @@ function makeDeps(
}
describe('createShutdownCheckpointPersist', () => {
it('stages the resume records the quit capture changed, for main to record', () => {
const records: SleepingAgentSessionRecord[] = [
{
paneKey: 'tab-1:leaf-1',
worktreeId: 'wt-1',
agent: 'claude',
providerSession: { key: 'session_id', id: 'session-1' },
prompt: '',
state: 'working',
capturedAt: 1,
updatedAt: 1
}
]
const deps = makeDeps({ captureSleepingAgentSessions: () => records })
createShutdownCheckpointPersist(deps).run()
expect(deps.stageBeforeUnloadSync).toHaveBeenCalledWith({
sessions: [{ state: { activeTabId: 't1' } }],
ui: { activeView: 'workspace' },
sleepRecords: records
})
})
it('does not fail the checkpoint when the sleeping-agent quit capture throws (STA-5505)', () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const deps = makeDeps({
@@ -1,4 +1,5 @@
import type { PersistedUIState } from '../../../shared/persisted-ui-state-types'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import { formatShutdownCheckpointFailureReason } from '../../../shared/renderer-shutdown-events'
import type { WorkspaceSessionHostSnapshot } from '../lib/workspace-session-host-persistence'
import { recordRendererCrashBreadcrumb } from '../lib/crash-breadcrumb-recorder'
@@ -6,13 +7,16 @@ import { recordRendererCrashBreadcrumb } from '../lib/crash-breadcrumb-recorder'
export type ShutdownCheckpointStageArgs = {
sessions: WorkspaceSessionHostSnapshot[]
ui: Partial<PersistedUIState>
/** Layout-owner builds: the resume records the quit capture changed, which main records. */
sleepRecords?: SleepingAgentSessionRecord[]
}
export type ShutdownCheckpointPersistDeps = {
shouldCaptureSession: () => boolean
/** Per-pane failures are already swallowed inside the capture loop. */
captureTerminalBuffers: () => void
captureSleepingAgentSessions: () => void
/** Returns the records it changed when main records them (a layout-owner build). */
captureSleepingAgentSessions: () => SleepingAgentSessionRecord[] | void
buildSessionSnapshots: () => WorkspaceSessionHostSnapshot[]
buildUiPatch: () => Partial<PersistedUIState>
hasDirtyOpenFiles: () => boolean
@@ -41,10 +45,11 @@ export function createShutdownCheckpointPersist(
let fullStagingFailedOnPriorAttempt = false
const run = (): void => {
const shouldCaptureSession = deps.shouldCaptureSession()
let sleepRecords: SleepingAgentSessionRecord[] | void = undefined
if (shouldCaptureSession) {
deps.captureTerminalBuffers()
try {
deps.captureSleepingAgentSessions()
sleepRecords = deps.captureSleepingAgentSessions()
} catch (error) {
// Why: blocking the shutdown here is what stranded STA-5505. The cost of
// continuing is real but bounded — done panes keep their weaker live-origin
@@ -74,7 +79,8 @@ export function createShutdownCheckpointPersist(
try {
deps.stageBeforeUnloadSync({
sessions: degraded ? [] : sessionSnapshots,
ui: deps.buildUiPatch()
ui: deps.buildUiPatch(),
...(sleepRecords && sleepRecords.length > 0 && !degraded ? { sleepRecords } : {})
})
} catch (error) {
// A durable-only stage has nothing safer left to fall back to.
@@ -1,5 +1,6 @@
import { useEffect } from 'react'
import { useAppStore } from '../store'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { createSessionWriteSubscriber } from '../lib/session-write-subscriber'
import { buildActiveViewUnloadPatch } from '../lib/active-view-persist'
import {
@@ -19,6 +20,8 @@ import {
createShutdownCheckpointGuard
} from '../lib/shutdown-checkpoint-guard'
import { createShutdownCheckpointPersist } from './shutdown-checkpoint-persist'
import { changedSleepingRecords } from '../store/terminals/terminal-sleep-layout-owner'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import { shutdownBufferCaptures } from '../components/terminal-pane/shutdown-buffer-captures'
import {
dispatchWindowCloseRequest,
@@ -33,6 +36,14 @@ import {
ORCA_RENDERER_UNLOAD_PREVENTED_EVENT
} from '../../../shared/renderer-shutdown-events'
/** The quit capture, returning the resume records it changed of tabs the window holds. */
function captureQuitSleepRecordsForMain(): SleepingAgentSessionRecord[] {
const previous = useAppStore.getState().sleepingAgentSessionsByPaneKey
useAppStore.getState().captureAllSleepingAgentSessions('quit')
const state = useAppStore.getState()
return changedSleepingRecords(state, previous, state.sleepingAgentSessionsByPaneKey).recorded
}
// Why: bound the resume-record loss window on a hard kill to ~1 min; capture skips unchanged records so per-tick cost is negligible.
const SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS = 60_000
@@ -81,8 +92,10 @@ export function useAppSessionPersistence(): void {
// which is in-memory. Capture them into the persisted sleeping-session
// map so a daemon/session death while the app is closed can still
// cold-restore via the agent's resume command (#5232).
captureSleepingAgentSessions: () =>
useAppStore.getState().captureAllSleepingAgentSessions('quit'),
// Switch point: the stage writes only the capture's changes into main's records.
captureSleepingAgentSessions: ORCA_LAYOUT_OWNER_IS_MAIN
? captureQuitSleepRecordsForMain
: () => useAppStore.getState().captureAllSleepingAgentSessions('quit'),
// Why: re-read state after capture() calls populated scrollback buffers
// into the store via Zustand setters. The shouldCaptureSession read is
// only for the gating flags and would miss those updates.
@@ -2,12 +2,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspace-layout-command-types'
const mocks = vi.hoisted(() => {
const sent: { reply: LayoutApplyReply } = { reply: { ok: true, result: {} } }
return { sent }
const sent: { reply: LayoutApplyReply | Error } = { reply: { ok: true, result: {} } }
const owner: { kind: 'resolved' | 'ambiguous' } = { kind: 'resolved' }
return { sent, owner }
})
vi.mock('@/lib/resolve-owner', () => ({ runtimeEnvironmentIdUnlessTwin: () => null }))
vi.mock('@/lib/resolve-owner', () => ({
runtimeEnvironmentIdUnlessTwin: () => null,
runtimeEnvironmentIdForWorkspaceOwner: () =>
mocks.owner.kind === 'resolved' ? { kind: 'resolved', environmentId: null } : mocks.owner
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({
callRuntimeRpc: vi.fn(async () => mocks.sent.reply)
callRuntimeRpc: vi.fn(async () => {
if (mocks.sent.reply instanceof Error) {
throw mocks.sent.reply
}
return mocks.sent.reply
})
}))
vi.mock('@/runtime/layout-frame-position', () => ({
afterLayoutFrame: (_seq: number | undefined, run: () => void) => run()
@@ -18,7 +28,7 @@ import { sendLayoutApply } from './layout-apply-send'
const RENAME = { type: 'renamePane', tabId: 'tab-1', leafId: 'leaf-1', title: 'Pane' } as const
async function send(reply: LayoutApplyReply) {
async function send(reply: LayoutApplyReply | Error) {
mocks.sent.reply = reply
const handlers = { apply: vi.fn(), asToday: vi.fn(), refused: vi.fn(), unapplied: vi.fn() }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the owner lookup is mocked and never reads the state.
@@ -34,7 +44,35 @@ async function send(reply: LayoutApplyReply) {
}
describe('sendLayoutApply', () => {
beforeEach(() => vi.spyOn(console, 'warn').mockImplementation(() => {}))
beforeEach(() => {
mocks.owner.kind = 'resolved'
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
// A window save no longer carries the change, so doing it here would last only until the next frame.
it.each([
{ reply: { ok: false, code: 'other_partition' } as const, code: 'other_partition' },
{ reply: { ok: false, code: 'host_not_owned' } as const, code: 'host_not_owned' },
{ reply: new Error('disk refused'), code: 'call_failed' }
])('logs a $code refusal and draws nothing of its own', async ({ reply, code }) => {
const handlers = await send(reply)
expect(handlers.refused).toHaveBeenCalledWith(code)
expect(handlers.asToday).not.toHaveBeenCalled()
expect(handlers.unapplied).not.toHaveBeenCalled()
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining(`[layout-owner] renamePane refused`),
...(reply instanceof Error ? ['disk refused'] : [code])
)
})
it('acts as today on a twin main routed to a paired server', async () => {
mocks.owner.kind = 'ambiguous'
const handlers = await send({ ok: false, code: 'host_not_owned' })
expect(handlers.asToday).toHaveBeenCalledOnce()
expect(handlers.refused).not.toHaveBeenCalled()
})
it('refuses a change on a workspace a WSL move has paused, drawing nothing', async () => {
const handlers = await send({ ok: false, code: 'wsl_move_in_progress' })
+30 -14
View File
@@ -1,5 +1,8 @@
// Store-free so store slices can send; `layout-owner-command` reads the store for other callers.
import { runtimeEnvironmentIdUnlessTwin } from '@/lib/resolve-owner'
import {
runtimeEnvironmentIdForWorkspaceOwner,
runtimeEnvironmentIdUnlessTwin
} from '@/lib/resolve-owner'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { afterLayoutFrame } from '@/runtime/layout-frame-position'
import { holdTabPaneTree } from '@/runtime/layout-owner-pane-intents'
@@ -14,7 +17,6 @@ import type {
GroupSide,
LayoutApplyReply,
LayoutCommandResult,
LayoutRefusalCode,
NewGroupSplit,
TabTitle
} from '../../../shared/workspace-layout/workspace-layout-command-types'
@@ -131,13 +133,14 @@ export type WindowLayoutCommand =
| { type: 'wake'; tabId: string; paneKeys: string[]; recordOnly: true }
| { type: 'setDefaultTabsApplied' }
type WindowRefusalCode = LayoutRefusalCode | 'wsl_move_in_progress'
/** `call_failed`: the call threw, and a thrown write is rolled back. */
type WindowRefusalCode = Extract<LayoutApplyReply, { ok: false }>['code'] | 'call_failed'
export type LayoutApplyHandlers = {
apply: (result: LayoutCommandResult) => void
asToday: () => void
refused?: (code: WindowRefusalCode) => void
/** Main's copy lacks the target, or the call threw; `asToday` when absent. A pane split or move
/** Main's copy lacks the target; `asToday` when absent. A pane split or move
* passes one: main keeps its pane trees over the window's saves, so that change would not last.
* A close keeps `asToday`: a pane main lacks has nothing to keep it. */
unapplied?: () => void
@@ -157,10 +160,11 @@ const PANE_COMMANDS: ReadonlySet<WindowLayoutCommand['type']> = new Set([
/**
* A layout command in a layout-owner build: main applies it first, then, once the window has the
* frame with main's change, `apply` shows main's result. `asToday` runs instead when main cannot
* act on it: a paired server holds the tab (#18202), a move's workspaces are in two partitions,
* main's copy lacks the workspace, tab, pane or group, or the call threw. `refused` on any other
* refusal. False when a paired server owns the workspace's layout; the caller acts as today.
* frame with main's change, `apply` shows main's result. `asToday` runs instead only when a paired
* server may hold the tab (#18202), or main's copy lacks the workspace, tab, pane or group. Any
* other refusal, and a call that threw, is logged and `refused`: main's frame stays what is drawn,
* since a window save no longer carries the change. False when a paired server owns the
* workspace's layout; the caller acts as today.
*/
export function sendLayoutApply(
state: Parameters<typeof runtimeEnvironmentIdUnlessTwin>[0],
@@ -190,17 +194,17 @@ export function sendLayoutApply(
(reply) =>
afterLayoutFrame(reply.frameSeq, () => {
try {
handleReply(command, reply, handlers)
handleReply(state, worktreeId, command, reply, handlers)
} finally {
release()
}
}),
(error: unknown) => {
// Why: a thrown write is rolled back. An indeterminate disk write is not, and may leave a
// pane main minted; accepted until main is the only writer.
console.warn(`[layout-owner] ${command.type} failed:`, errorMessage(error))
// pane main minted, which its frame then draws.
console.warn(`[layout-owner] ${command.type} refused: call_failed`, errorMessage(error))
try {
unapplied(handlers)
handlers.refused?.('call_failed')
} finally {
release()
}
@@ -214,16 +218,18 @@ export function sendLayoutApply(
}
function handleReply(
state: Parameters<typeof runtimeEnvironmentIdUnlessTwin>[0],
worktreeId: string,
command: WindowLayoutCommand,
reply: LayoutApplyReply,
handlers: LayoutApplyHandlers
): void {
if (reply.ok) {
handlers.apply(reply.result)
} else if (reply.code === 'host_not_owned' || reply.code === 'other_partition') {
} else if (reply.code === 'host_not_owned' && mayBePairedServers(state, worktreeId)) {
handlers.asToday()
} else if (mainLacksTarget(reply.code)) {
// Why: main's copy lags the window's here (the window still saves its tabs).
// Why: main's copy can lag the window's: a tab the window drew before main's create answered.
console.warn(`[layout-owner] ${command.type} found no target:`, reply.code)
unapplied(handlers)
} else {
@@ -232,6 +238,16 @@ function handleReply(
}
}
/** A twin: main may route it to a paired server, whose layout the window still mirrors. */
function mayBePairedServers(
state: Parameters<typeof runtimeEnvironmentIdUnlessTwin>[0],
worktreeId: string
): boolean {
return (
runtimeEnvironmentIdForWorkspaceOwner(state, { workspaceId: worktreeId }).kind !== 'resolved'
)
}
function unapplied(handlers: LayoutApplyHandlers): void {
if (handlers.unapplied) {
handlers.unapplied()
@@ -19,7 +19,11 @@ const mocks = vi.hoisted(
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc }))
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
vi.mock('@/lib/resolve-owner', () => ({
runtimeEnvironmentIdUnlessTwin: () => mocks.ownerEnvironmentId
runtimeEnvironmentIdUnlessTwin: () => mocks.ownerEnvironmentId,
runtimeEnvironmentIdForWorkspaceOwner: () => ({
kind: 'resolved',
environmentId: mocks.ownerEnvironmentId
})
}))
const SPLIT = {
@@ -100,8 +104,7 @@ describe('sendLayoutCommandToOwner', () => {
[
'main lacks the workspace',
() => mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'workspace_not_found' })
],
['the call fails', () => mocks.callRuntimeRpc.mockRejectedValue(new Error('rpc_failed'))]
]
])('acts as today when %s', async (_case, arrange) => {
arrange()
const on = handlers()
@@ -112,15 +115,19 @@ describe('sendLayoutCommandToOwner', () => {
expect(on.refused).not.toHaveBeenCalled()
})
it.each([
['main lacks the pane', { ok: false, code: 'pane_not_found' }],
['the call fails', new Error('rpc_failed')]
])('draws nothing for a pane command passing `unapplied` when %s', async (_case, answer) => {
if (answer instanceof Error) {
mocks.callRuntimeRpc.mockRejectedValue(answer)
} else {
mocks.callRuntimeRpc.mockResolvedValue(answer)
}
it('refuses, drawing nothing, when the call fails', async () => {
mocks.callRuntimeRpc.mockRejectedValue(new Error('rpc_failed'))
const on = { ...handlers(), unapplied: vi.fn() }
sendLayoutCommandToOwner('worktree-1', SPLIT, on)
await vi.waitFor(() => expect(on.refused).toHaveBeenCalledWith('call_failed'))
expect(on.asToday).not.toHaveBeenCalled()
expect(on.unapplied).not.toHaveBeenCalled()
})
it('draws nothing for a pane command passing `unapplied` when main lacks the pane', async () => {
mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'pane_not_found' })
const on = { ...handlers(), unapplied: vi.fn() }
sendLayoutCommandToOwner('worktree-1', SPLIT, on)
@@ -188,13 +195,14 @@ describe('sendLayoutCommandToOwner', () => {
await vi.waitFor(() => expect(on.refused).toHaveBeenCalledWith('last_pane'))
})
it('acts as today when main says a paired server holds the tab', async () => {
it('refuses a workspace main routes to no host of its own, drawing nothing', async () => {
mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'host_not_owned' })
const on = handlers()
expect(sendLayoutCommandToOwner('worktree-1', SPLIT, on)).toBe(true)
await vi.waitFor(() => expect(on.asToday).toHaveBeenCalled())
await vi.waitFor(() => expect(on.refused).toHaveBeenCalledWith('host_not_owned'))
expect(on.asToday).not.toHaveBeenCalled()
expect(on.apply).not.toHaveBeenCalled()
})
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTabsSliceMockApi } from '../tabs-slice-test-harness'
import { createTestStore, makeTab } from '../store-test-helpers'
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
vi.mock('../../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
createTabsSliceMockApi()
const WT = 'repo1::/tmp/feature'
// Main draws the tab bar from its frames; these shared paths must not write structure of their own.
describe('shared tab paths in a layout-owner build', () => {
let store: ReturnType<typeof createTestStore>
beforeEach(() => {
store = createTestStore()
})
it('reconcile counts what would render and leaves the tab bar to main', () => {
const entry = {
id: 'stale-terminal',
entityId: 'stale-terminal',
groupId: 'g-1',
worktreeId: WT,
contentType: 'terminal' as const,
label: 'Terminal 1',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
store.setState({
unifiedTabsByWorktree: { [WT]: [entry] },
groupsByWorktree: {
[WT]: [{ id: 'g-1', worktreeId: WT, activeTabId: entry.id, tabOrder: [entry.id] }]
},
activeGroupIdByWorktree: { [WT]: 'g-1' },
tabsByWorktree: { [WT]: [] }
})
const before = store.getState()
expect(store.getState().reconcileWorktreeTabModel(WT)).toEqual({
renderableTabCount: 0,
activeRenderableTabId: null
})
store.getState().reconcileWorktreeTabModels([WT])
expect(store.getState().unifiedTabsByWorktree).toBe(before.unifiedTabsByWorktree)
expect(store.getState().groupsByWorktree).toBe(before.groupsByWorktree)
})
it('reconnect advertises restored terminals without rewriting their rows', async () => {
const rows = { [WT]: [makeTab({ id: 'tab-1', worktreeId: WT, ptyId: null })] }
store.setState({
tabsByWorktree: rows,
terminalLayoutsByTabId: {},
pendingReconnectWorktreeIds: [WT],
pendingReconnectTabByWorktree: { [WT]: ['tab-1'] },
pendingReconnectPtyIdByTabId: { 'tab-1': 'pty-1' }
})
await store.getState().reconnectPersistedTerminals()
expect(store.getState().ptyIdsByTabId['tab-1']).toEqual(['pty-1'])
expect(store.getState().tabsByWorktree).toBe(rows)
expect(store.getState().workspaceSessionReady).toBe(true)
})
})
@@ -10,6 +10,7 @@ import { buildHydratedTabState } from '../tabs-hydration'
import { projectWorktreeTabModelReconciliation } from './tabs-reconciliation'
import { createWorktreeTabModelReconciliationBatch } from './tabs-reconciliation-batch'
import type { AppState } from '../../types'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch'
function replaceWorkspaceRecordKeys<T>(
current: Record<string, T>,
@@ -59,6 +60,10 @@ export function createTabsSessionActions(
if (worktreeIds.length === 0) {
return
}
// Switch point: main's frames draw the tab bar, so reconcile writes no structure of its own.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
return
}
const patch = projectWorktreeTabModelReconciliations(get(), worktreeIds)
if (Object.keys(patch).length > 0) {
set(patch)
@@ -67,7 +72,8 @@ export function createTabsSessionActions(
reconcileWorktreeTabModel: (worktreeId) => {
const reconciliation = projectWorktreeTabModelReconciliation(get(), worktreeId)
if (Object.keys(reconciliation.patch).length > 0) {
// Switch point: the counts only; main's frames draw the tab bar.
if (!ORCA_LAYOUT_OWNER_IS_MAIN && Object.keys(reconciliation.patch).length > 0) {
set(reconciliation.patch)
}
return {
@@ -40,35 +40,49 @@ export function recordPaneSleepThroughLayoutOwner(
}
}
type HeldRecordsState = Pick<AppState, 'tabsByWorktree'>
/**
* The resume records `next` sets or replaces since `previous`, and the ones it drops, of panes in
* tabs the window holds: a closed tab's or removed workspace's records went with it in main, and a
* capture from a stale agent status names a tab main no longer has.
*/
export function changedSleepingRecords(
state: HeldRecordsState,
previous: SleepingRecords,
next: SleepingRecords
): { recorded: SleepingAgentSessionRecord[]; dropped: [string, SleepingAgentSessionRecord][] } {
const held = ([paneKey, record]: [string, SleepingAgentSessionRecord]): boolean =>
state.tabsByWorktree[record.worktreeId]?.some(
(tab) => tab.id === parsePaneKey(paneKey)?.tabId
) ?? false
return {
recorded: Object.entries(next)
.filter((entry) => previous[entry[0]] !== entry[1] && held(entry))
.map(([, record]) => record),
dropped: Object.entries(previous).filter((entry) => !(entry[0] in next) && held(entry))
}
}
/**
* Every change of the window's resume records, as record-only commands: live and quit captures of
* running agents, records a resume or close dropped, and the rest no sleep or wake command sends.
* Without these main learned the records only from the window's session save.
*/
export function recordSleepingRecordChangesThroughLayoutOwner(
state: WorktreeRuntimeOwnerState & Pick<AppState, 'tabsByWorktree'>,
state: WorktreeRuntimeOwnerState & HeldRecordsState,
previous: SleepingRecords,
next: SleepingRecords
): void {
// Only panes of tabs the window holds: a closed tab's or removed workspace's records went with it
// in main, and a capture from a stale agent status names a tab main no longer has.
const held = ([paneKey, record]: [string, SleepingAgentSessionRecord]): boolean =>
state.tabsByWorktree[record.worktreeId]?.some(
(tab) => tab.id === parsePaneKey(paneKey)?.tabId
) ?? false
const changedByWorktree = new Map<string, Record<string, SleepingAgentSessionRecord>>()
for (const [paneKey, record] of Object.entries(next)) {
if (previous[paneKey] !== record && held([paneKey, record])) {
changedByWorktree.set(record.worktreeId, {
...changedByWorktree.get(record.worktreeId),
[paneKey]: record
})
}
const { recorded, dropped } = changedSleepingRecords(state, previous, next)
for (const [worktreeId, records] of Map.groupBy(recorded, (record) => record.worktreeId)) {
recordPaneSleepThroughLayoutOwner(
state,
worktreeId,
Object.fromEntries(records.map((record) => [record.paneKey, record])),
true
)
}
for (const [worktreeId, records] of changedByWorktree) {
recordPaneSleepThroughLayoutOwner(state, worktreeId, records, true)
}
const dropped = Object.entries(previous).filter((entry) => !(entry[0] in next) && held(entry))
for (const [tabId, entries] of byTab(dropped, ([paneKey]) => paneKey)) {
recordLayoutApply(state, entries[0]![1].worktreeId, {
type: 'wake',
@@ -86,9 +86,8 @@ export function terminalTabCreateCommand(
}
/**
* Sends a tab the window just drew to main. Nothing to undo on any answer: main holding the same
* pane already, or a paired server or no partition holding the workspace, leaves the window's own
* session save to carry the tab, as today.
* Sends a tab the window just drew to main. Nothing to undo on any answer: main's frame is what
* stays drawn, since the window's session save no longer carries the tab. A refusal is logged.
*/
export function recordCreatedTerminalTabInLayoutOwner(
state: AppState,
@@ -105,13 +104,13 @@ export function recordCreatedTerminalTabInLayoutOwner(
}
void callRuntimeRpc<LayoutApplyReply>({ kind: 'local' }, 'layout.apply', { command }).then(
(reply) => {
if (!reply.ok && reply.code !== 'host_not_owned') {
if (!reply.ok) {
console.warn('[layout-owner] createTerminalTab refused:', reply.code)
}
},
(error: unknown) => {
console.warn(
'[layout-owner] createTerminalTab failed:',
'[layout-owner] createTerminalTab refused: call_failed',
error instanceof Error ? error.message : error
)
}
@@ -2,6 +2,7 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import { buildByIdIndex, buildWorktreeByIdIndex } from '../slices/worktree-by-id-index'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
export function createWorkspaceTerminalReconnectActions(
set: TerminalStoreSet,
@@ -117,7 +118,10 @@ export function createWorkspaceTerminalReconnectActions(
return
}
set({
...(reconnectedTabsByWorktree ? { tabsByWorktree: reconnectedTabsByWorktree } : {}),
// Switch point: main's frames draw the rows, with main's own bindings.
...(reconnectedTabsByWorktree && !ORCA_LAYOUT_OWNER_IS_MAIN
? { tabsByWorktree: reconnectedTabsByWorktree }
: {}),
...(reconnectedPtyIdsByTabId ? { ptyIdsByTabId: reconnectedPtyIdsByTabId } : {}),
workspaceSessionReady: true,
pendingReconnectWorktreeIds: [],
+154
View File
@@ -0,0 +1,154 @@
/**
* Whether a workspace gets its first terminal after a relaunch: one the user emptied by closing
* its terminals stays empty, one holding only an editor tab stays as it is, and one never
* opened gets a terminal when first shown. Main decides this from the terminal rows it keeps,
* so the answer must be the same whether or not the window's saves carry its tabs.
*/
import path from 'node:path'
import { writeFileSync } from 'node:fs'
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'
type WorkspaceTabs = { terminals: number; editors: number }
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)
}
async function workspaceTabs(page: Page, worktreeId: string): Promise<WorkspaceTabs> {
return page.evaluate((id) => {
const state = window.__store!.getState()
const entries = state.unifiedTabsByWorktree[id] ?? []
return {
terminals: (state.tabsByWorktree[id] ?? []).length,
editors: entries.filter((entry) => entry.contentType === 'editor').length
}
}, worktreeId)
}
async function waitForTabs(page: Page, worktreeId: string, expected: WorkspaceTabs) {
await expect.poll(() => workspaceTabs(page, worktreeId), { timeout: 30_000 }).toEqual(expected)
}
async function closeTerminals(page: Page, worktreeId: string): Promise<void> {
await page.evaluate((id) => {
const state = window.__store!.getState()
for (const tab of state.tabsByWorktree[id] ?? []) {
state.closeTab(tab.id)
}
}, worktreeId)
}
async function openEditor(
page: Page,
worktreeId: string,
filePath: string,
readOnly?: true
): Promise<void> {
await page.evaluate(
({ id, file, readOnly }) => {
window.__store!.getState().openFile({
filePath: file,
relativePath: file.split('/').pop()!,
worktreeId: id,
language: 'plaintext',
mode: 'edit',
...(readOnly ? { readOnly } : {})
})
},
{ id: worktreeId, file: filePath, readOnly }
)
}
async function isReadOnly(page: Page, filePath: string): Promise<boolean | null> {
return page.evaluate(
(file) =>
window.__store!.getState().openFiles.find((open) => open.filePath === file)?.readOnly ?? null,
filePath
)
}
async function worktreePath(page: Page, worktreeId: string): Promise<string> {
return page.evaluate(
(id) =>
window
.__store!.getState()
.allWorktrees()
.find((worktree) => worktree.id === id)!.path,
worktreeId
)
}
// The editor-only workspace's file also opens read-only, then again for editing: a relaunch
// restores it editable, as the reopen left it.
test('a relaunch seeds only a workspace that was never opened', async ({
testRepoPath
}, testInfo) => {
const session = createRestartSession(testInfo)
const refusals: string[] = []
const watch = (page: Page) =>
page.on('console', (message) => {
if (/\[layout-owner\].*(refused|failed)/.test(message.text())) {
refusals.push(message.text())
}
})
let app: Awaited<ReturnType<typeof session.launch>>['app'] | undefined
try {
const first = await session.launch()
app = first.app
watch(first.page)
await waitForSessionReady(first.page)
await attachRepoAndOpenTerminal(first.page, testRepoPath)
const stamp = Date.now()
const emptied = await createWorktree(first.page, `relaunch-emptied-${stamp}`)
const editorOnly = await createWorktree(first.page, `relaunch-editor-${stamp}`)
const neverOpened = await createWorktree(first.page, `relaunch-never-${stamp}`)
await switchToWorktree(first.page, emptied)
await waitForTabs(first.page, emptied, { terminals: 1, editors: 0 })
await closeTerminals(first.page, emptied)
await waitForTabs(first.page, emptied, { terminals: 0, editors: 0 })
await switchToWorktree(first.page, editorOnly)
await waitForTabs(first.page, editorOnly, { terminals: 1, editors: 0 })
const notes = path.join(await worktreePath(first.page, editorOnly), 'relaunch-notes.txt')
writeFileSync(notes, 'notes\n')
await openEditor(first.page, editorOnly, notes, true)
await expect.poll(() => isReadOnly(first.page, notes)).toBe(true)
await openEditor(first.page, editorOnly, notes)
await expect.poll(() => isReadOnly(first.page, notes)).toBe(null)
await closeTerminals(first.page, editorOnly)
await waitForTabs(first.page, editorOnly, { terminals: 0, editors: 1 })
// Long enough for the window's debounced save and main's commands to land.
await first.page.waitForTimeout(2_000)
await session.close(app)
app = undefined
const second = await session.launch()
app = second.app
watch(second.page)
await waitForSessionReady(second.page)
for (const worktreeId of [emptied, editorOnly, neverOpened]) {
await switchToWorktree(second.page, worktreeId)
// A first terminal is created shortly after a workspace is shown, or not at all.
await second.page.waitForTimeout(3_000)
}
await waitForTabs(second.page, neverOpened, { terminals: 1, editors: 0 })
expect(await workspaceTabs(second.page, emptied)).toEqual({ terminals: 0, editors: 0 })
expect(await workspaceTabs(second.page, editorOnly)).toEqual({ terminals: 0, editors: 1 })
expect(await isReadOnly(second.page, notes)).toBe(null)
expect(refusals).toEqual([])
} finally {
await session.dispose(app)
}
})
@@ -127,28 +127,32 @@ async function activateTab(page: Page, tabId: string): Promise<void> {
}, tabId)
}
/** Moves the tab through the window's own tab-move path, which a layout-owner build sends to main. */
async function moveTabIntoRightGroup(
app: ElectronApplication,
page: Page,
worktreeId: string,
anchorTabId: string,
movedTabId: string
): Promise<void> {
await page.evaluate(
const move = await page.evaluate(
({ wtId, anchorTabId, movedTabId }) => {
const store = window.__store!.getState()
const tabs = store.unifiedTabsByWorktree[wtId] ?? []
const tabs = window.__store!.getState().unifiedTabsByWorktree[wtId] ?? []
const anchor = tabs.find((tab) => tab.entityId === anchorTabId)
const moved = tabs.find((tab) => tab.entityId === movedTabId)
if (
!anchor ||
!moved ||
!store.dropUnifiedTab(moved.id, { groupId: anchor.groupId, splitDirection: 'right' })
) {
throw new Error('Could not move the tab into a side-by-side group')
if (!anchor || !moved) {
throw new Error('Could not find the tabs to place side by side')
}
return { tabId: moved.id, targetGroupId: anchor.groupId }
},
{ wtId: worktreeId, anchorTabId, movedTabId }
)
await app.evaluate(
({ BrowserWindow }, payload) => {
BrowserWindow.getAllWindows()[0]?.webContents.send('ui:moveSessionTab', payload)
},
{ worktreeId, kind: 'split' as const, splitDirection: 'right' as const, ...move }
)
await expect
.poll(() =>
page.evaluate(
@@ -224,7 +228,7 @@ async function relaunchWithSleepingAgents(
}
}
if (options.secondTabInRightGroup) {
await moveTabIntoRightGroup(page, worktreeId, tabIds[0]!, tabIds[1]!)
await moveTabIntoRightGroup(firstApp, page, worktreeId, tabIds[0]!, tabIds[1]!)
}
await activateTab(page, firstTabId)