A paired desktop draws a layout-owner server's workspaces from its layout stream (switch-on builds only) (#31181)

* review: shorten a test comment; the methods file header names layout.apply

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

* wip(h5b): a lost remote layout stream reopens with the local backoff

* wip(h5b): server frame id translation (pending the binding handle)

* wip(h5b): frame syncs take a source; the mirror gates frame-drawn server workspaces and indexes handles; ids translate with session.tabs handles

* wip(h5b): one layout stream per capable paired server, handing workspaces between frames and the mirror

* wip(h5b): tests for server frames; layout.getRemoteSnapshots returns only paired servers (L18)

* wip(h5b): re-gate a server workspace when its routing arrives after the snapshot; e2e hook and spec

* wip(h5b): late-owner test; e2e spec on the switch-on leg

* wip(h5b): server streams follow every paired capable server, not the focused one; a known-kind guard without a cast

* wip(h5b): server-frames tests read real paired-server state; no casts

* fix(h5b): a handed-back workspace is the mirror's alone; frozen frames draw only gated workspaces

* fix(h5b): a workspace rejoining the gate takes the full build

---------

Co-authored-by: E2E Test <e2e@test.local>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jinwoo Hong
2026-10-20 14:09:35 -04:00
committed by GitHub
co-authored by Claude E2E Test
parent 99e91c16ae
commit b8a5c8f910
32 changed files with 3174 additions and 125 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
uses: ./.github/workflows/e2e.yml
with:
ref: ${{ inputs.ref || github.sha }}
test_files: '["tests/e2e/workspace-layout-oracle-headless.spec.ts","tests/e2e/workspace-layout-oracle.spec.ts","tests/e2e/cli-file-open-position.spec.ts","tests/e2e/relaunch-first-terminal.spec.ts","tests/e2e/relaunch-sleeping-agents-resume-on-view.spec.ts","tests/e2e/terminal-cold-activation-deferral.spec.ts","tests/e2e/cli-first-worktree-single-terminal.spec.ts","tests/e2e/unopened-workspace-agent-single-resume.spec.ts","tests/e2e/terminal-pane-close-layout-consistency.spec.ts","tests/e2e/golden-file-open-edit-save.spec.ts","tests/e2e/golden-source-control-open-diff.spec.ts","tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts","tests/e2e/floating-workspace-pane-height.spec.ts","tests/e2e/editor-model-host-isolation.spec.ts","tests/e2e/editor-tab-selection-restore.spec.ts","tests/e2e/finished-agent-ghost-resume.spec.ts","tests/e2e/live-background-terminal-mount-authority.spec.ts","tests/e2e/workspace-back-forward-navigation.spec.ts","tests/e2e/worktree-lineage-toggle-shortcut.spec.ts","tests/e2e/local-cli-terminal-retention.spec.ts","tests/e2e/default-tabs-mark-crash-relaunch.spec.ts","tests/e2e/terminal-cli-split-closed-before-mount.spec.ts","tests/e2e/layout-stream-loss-reopen.spec.ts","tests/e2e/terminal-tab-close-restart-persistence.spec.ts","tests/e2e/artificial-opencode-terminal-load.spec.ts","tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts","tests/e2e/headless-paired-remote-terminal-reveal-grid.spec.ts","tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts","tests/e2e/headless-paired-remote-terminal-viewer-size.spec.ts","tests/e2e/headless-serve-browser-tab-restart.spec.ts","tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts","tests/e2e/headless-serve-desktop-activation.spec.ts","tests/e2e/headless-serve-focused-terminal-create.spec.ts","tests/e2e/headless-serve-mobile-chat-tab-order.spec.ts","tests/e2e/headless-serve-mobile-native-chat-identity.spec.ts","tests/e2e/headless-serve-session-history-naming-requests.spec.ts","tests/e2e/layout-compat-old-phone-on-new-server.spec.ts","tests/e2e/layout-compat-serve-restart-keeps-at-at-terminals.spec.ts","tests/e2e/paired-mobile-serve-restart-terminal-tabs.spec.ts","tests/e2e/paired-mobile-desktop-tab-composition.spec.ts","tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts","tests/e2e/ssh-orcad-"]'
test_files: '["tests/e2e/workspace-layout-oracle-headless.spec.ts","tests/e2e/workspace-layout-oracle.spec.ts","tests/e2e/cli-file-open-position.spec.ts","tests/e2e/relaunch-first-terminal.spec.ts","tests/e2e/relaunch-sleeping-agents-resume-on-view.spec.ts","tests/e2e/terminal-cold-activation-deferral.spec.ts","tests/e2e/cli-first-worktree-single-terminal.spec.ts","tests/e2e/unopened-workspace-agent-single-resume.spec.ts","tests/e2e/terminal-pane-close-layout-consistency.spec.ts","tests/e2e/golden-file-open-edit-save.spec.ts","tests/e2e/golden-source-control-open-diff.spec.ts","tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts","tests/e2e/floating-workspace-pane-height.spec.ts","tests/e2e/editor-model-host-isolation.spec.ts","tests/e2e/editor-tab-selection-restore.spec.ts","tests/e2e/finished-agent-ghost-resume.spec.ts","tests/e2e/live-background-terminal-mount-authority.spec.ts","tests/e2e/workspace-back-forward-navigation.spec.ts","tests/e2e/worktree-lineage-toggle-shortcut.spec.ts","tests/e2e/local-cli-terminal-retention.spec.ts","tests/e2e/default-tabs-mark-crash-relaunch.spec.ts","tests/e2e/terminal-cli-split-closed-before-mount.spec.ts","tests/e2e/layout-stream-loss-reopen.spec.ts","tests/e2e/terminal-tab-close-restart-persistence.spec.ts","tests/e2e/artificial-opencode-terminal-load.spec.ts","tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts","tests/e2e/headless-paired-remote-terminal-reveal-grid.spec.ts","tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts","tests/e2e/headless-paired-remote-terminal-viewer-size.spec.ts","tests/e2e/headless-serve-browser-tab-restart.spec.ts","tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts","tests/e2e/headless-serve-desktop-activation.spec.ts","tests/e2e/headless-serve-focused-terminal-create.spec.ts","tests/e2e/headless-serve-mobile-chat-tab-order.spec.ts","tests/e2e/headless-serve-mobile-native-chat-identity.spec.ts","tests/e2e/headless-serve-session-history-naming-requests.spec.ts","tests/e2e/layout-compat-old-phone-on-new-server.spec.ts","tests/e2e/layout-compat-serve-restart-keeps-at-at-terminals.spec.ts","tests/e2e/paired-mobile-serve-restart-terminal-tabs.spec.ts","tests/e2e/paired-mobile-desktop-tab-composition.spec.ts","tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts","tests/e2e/paired-desktop-server-layout-frames.spec.ts","tests/e2e/ssh-orcad-"]'
run_changed_e2e: true
needs_build: true
artifact_suffix: -layout-owner-switch-on
@@ -70,7 +70,7 @@ describe('the layout intents capability', () => {
})
describe('layout methods on a layout-owner server', () => {
// Past the scope check a desktop meets the next one: a stream asked for as a request, or bad params.
// Past the scope check: a stream asked for as a request, or bad params.
it.each([
['layout.subscribe', 'method_not_supported'],
['layout.unsubscribe', 'invalid_argument'],
@@ -6,6 +6,12 @@ import { ALL_RPC_METHODS } from './index'
import { RUNTIME_CAPABILITIES } from '../../../../shared/protocol-version'
import { MOBILE_RPC_METHOD_ALLOWLIST } from '../../runtime-rpc/runtime-rpc-mobile-method-allowlist'
const paired = vi.hoisted(() => ({ ids: ['one', 'two'] }))
vi.mock('../../../../shared/runtime-environment-store', async (importOriginal) => ({
...(await importOriginal<object>()),
listEnvironments: () => paired.ids.map((id) => ({ id }))
}))
const methods = eraseRpcMethods(ALL_RPC_METHODS)
const subscribe = methods.find((method) => method.name === 'layout.subscribe')
const unsubscribeMethod = methods.find((method) => method.name === 'layout.unsubscribe')
@@ -162,5 +168,11 @@ describe('layout.getRemoteSnapshots', () => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime from its context.
const result = await getRemoteSnapshots.handler(undefined, { runtime } as RpcContext)
expect(result).toEqual({ snapshots })
// A server unpaired since its snapshot was stored seeds nothing.
paired.ids = ['two']
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime from its context.
const afterRemoval = await getRemoteSnapshots.handler(undefined, { runtime } as RpcContext)
expect(afterRemoval).toEqual({ snapshots: { two: snapshots.two } })
})
})
@@ -1,4 +1,4 @@
// The read-only layout stream (design 2.4) and the server snapshot read (design 6.3). Paired
// The layout stream (design 2.4), the server snapshot read (design 6.3) and layout.apply. Paired
// desktops may call them; a desktop relies on them only on a server advertising
// `layout.intents.v1` (layout-intents-capability.ts). Not on the mobile allowlist.
@@ -9,6 +9,8 @@ import {
} from '../../../../shared/rpc-contract/workspace-layout-params'
import type { WorkspaceLayoutStreamFrame } from '../../../../shared/workspace-layout/workspace-layout-stream-frames'
import { defineMethod, defineStreamingMethod } from '../core'
import { getAppEnvironment } from '../../../../shared/app-environment'
import { listEnvironments } from '../../../../shared/runtime-environment-store'
let layoutSubscriptionSeq = 0
@@ -75,7 +77,17 @@ export const WORKSPACE_LAYOUT_METHODS = [
name: 'layout.getRemoteSnapshots',
permission: 'workspace',
params: null,
handler: async (_params, { runtime }) => ({ snapshots: runtime.getRemoteLayoutSnapshots() })
handler: async (_params, { runtime }) => {
// Only servers still paired: a removed server's last layout must not seed a view again.
const known = new Set(
listEnvironments(getAppEnvironment().getPath('userData')).map(({ id }) => id)
)
return {
snapshots: Object.fromEntries(
Object.entries(runtime.getRemoteLayoutSnapshots()).filter(([id]) => known.has(id))
)
}
}
})
]
@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-workspace-tab-models'
const hoisted = vi.hoisted(() => ({
layoutOwnerIsMain: false,
showServerLayoutFrames: vi.fn(),
showStartupLayoutFrames: vi.fn()
}))
vi.mock('../../../shared/layout-owner-switch', () => ({
get ORCA_LAYOUT_OWNER_IS_MAIN() {
return hoisted.layoutOwnerIsMain
}
}))
vi.mock('../runtime/server-layout-frames', () => ({
showServerLayoutFrames: hoisted.showServerLayoutFrames
}))
vi.mock('../runtime/workspace-layout-frame-sync', () => ({
showStartupLayoutFrames: hoisted.showStartupLayoutFrames
}))
vi.mock('../runtime/sleeping-records-to-layout-owner', () => ({
followSleepingRecordsInLayoutOwner: vi.fn()
}))
describe('paired servers’ layout streams at restore', () => {
beforeEach(() => {
hoisted.showServerLayoutFrames.mockReset()
hoisted.showStartupLayoutFrames.mockReset()
})
it('open with the layout-owner switch on', () => {
hoisted.layoutOwnerIsMain = true
reconcileHydratedWorkspaceTabModels({ tabsByWorktree: { 'wt-a': [] } }, vi.fn())
expect(hoisted.showStartupLayoutFrames).toHaveBeenCalledTimes(1)
expect(hoisted.showServerLayoutFrames).toHaveBeenCalledTimes(1)
})
it('never open with the switch off, though restore reconciled', () => {
hoisted.layoutOwnerIsMain = false
const reconcile = vi.fn()
reconcileHydratedWorkspaceTabModels({ tabsByWorktree: { 'wt-a': [] } }, reconcile)
// Presence: the restore path ran.
expect(reconcile).toHaveBeenCalledWith(['wt-a'])
expect(hoisted.showServerLayoutFrames).not.toHaveBeenCalled()
expect(hoisted.showStartupLayoutFrames).not.toHaveBeenCalled()
})
})
@@ -1,6 +1,7 @@
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { showStartupLayoutFrames } from '../runtime/workspace-layout-frame-sync'
import { showServerLayoutFrames } from '../runtime/server-layout-frames'
import { followSleepingRecordsInLayoutOwner } from '../runtime/sleeping-records-to-layout-owner'
/** Reconcile every workspace loaded during boot so stale unified-tab subsets converge. */
@@ -15,10 +16,11 @@ export function reconcileHydratedWorkspaceTabModels(
reconcileWorktreeTabModels(reconciled)
}
// Switch point: from main's first layout snapshot on, the frames' workspaces replace what restore
// built for them. Records frames do not carry (sleeping agents, closed tabs, default-tab marks,
// built for them, and each capable paired server's do from its own stream's. Records frames do not carry (sleeping agents, closed tabs, default-tab marks,
// what reattaches) and the view stay restore's; main records later resume-record changes itself.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
showStartupLayoutFrames()
showServerLayoutFrames()
followSleepingRecordsInLayoutOwner()
}
return reconciled
+2
View File
@@ -139,6 +139,8 @@ declare global {
__e2ePtyAppliedSizeReadDelayMs?: number
__terminalPtyPreSpawnE2EBarrier?: TerminalPtyPreSpawnE2EBarrier
__endLocalLayoutStreamE2E?: LocalLayoutStreamE2EEnd
/** E2E builds only: whether a paired server's layout frames, not the mirror, draw the workspace. */
__isFrameDrawnServerWorkspaceE2E?: (environmentId: string, worktreeId: string) => boolean
}
}
@@ -0,0 +1,68 @@
// The layout stream a frame sync reads: this window's own main's, or a paired server's under this
// window's ids. Only the local stream's positions answer this window's layout.apply replies.
import type { AppState } from '@/store/types'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
import {
beginLayoutFramePositions,
endLayoutFramePositions,
noteLayoutFramePosition
} from './layout-frame-position'
import { subscribeRuntimeLayout, type RuntimeLayoutStreamStatus } from './runtime-layout-client'
import { framesDrawWorkspace } from './workspace-layout-frame-drawn-workspaces'
/** Whose layout stream a sync shows, and which of the window's workspaces it draws. */
export type LayoutFrameSource = {
target: RuntimeClientTarget
draws: (state: AppState) => (key: string) => boolean
/** Frames under this window's ids; the local stream's already are. */
translate?: (frame: WorkspaceLayoutChangeFrame) => WorkspaceLayoutChangeFrame
onStatus?: (status: RuntimeLayoutStreamStatus) => void
}
export const LOCAL_LAYOUT_FRAMES: LayoutFrameSource = {
target: { kind: 'local' },
draws: framesDrawWorkspace
}
/** `onOpen(false)` once no frame will come, `onOpen(true)` when a lost stream reopens. */
export function openLayoutFrameStream(
source: LayoutFrameSource,
receive: (frame: WorkspaceLayoutChangeFrame) => void,
onOpen: (open: boolean) => void
): { close: () => void } {
const local = source.target.kind === 'local'
let open = true
if (local) {
beginLayoutFramePositions()
}
return subscribeRuntimeLayout(
source.target,
{},
(frame) => receive(source.translate ? source.translate(frame) : frame),
(status) => {
if (status.state === 'lost' || status.state === 'refused') {
console.warn('[layout-owner] layout stream stopped:', status)
}
if (status.state === 'connecting' && !open) {
// The stream reopens after a loss; replies wait for its frames again.
open = true
if (local) {
beginLayoutFramePositions()
}
onOpen(true)
} else if (status.state !== 'connecting' && status.state !== 'live') {
open = false
if (local) {
endLayoutFramePositions()
}
onOpen(false)
}
source.onStatus?.(status)
},
// After the frame's flush. A held tab may still show as the window drew it, so a waiting reply
// checks the store rather than assume the frame shows.
(seq) => queueMicrotask(() => local && open && noteLayoutFramePosition(seq))
)
}
@@ -206,46 +206,6 @@ describe.each(targets)('runtime layout client over the %s transport', (_name, ta
expect(positions).toEqual([3, 4, 5, 7])
})
it.skipIf(target.kind === 'local')(
'after a lost stream, keeps the last state and never reopens it',
async () => {
subscribe()
const first = await opened(1)
first.send(
snapshot([
{ key: 'a', layout: layout('a', ['t1', 't2']) },
{ key: 'b', layout: layout('b') }
])
)
first.drop()
expect(first.unsubscribed).toBe(true)
expect(status).toEqual({ state: 'lost' })
await vi.advanceTimersByTimeAsync(60_000)
expect(host.streams).toHaveLength(1)
// The last state stays; a late frame from the dropped stream changes nothing.
first.send({ type: 'removed', key: 'a' })
expect(cache).toEqual({ a: layout('a', ['t1', 't2']), b: layout('b') })
}
)
it.each([
['a failed subscribe', () => host.rejectNextSubscribe(), async () => {}],
['an error frame', () => {}, async () => (await opened(1)).fail('stream failed')]
])('stops after %s without retrying', async (_case, before, fail) => {
if (target.kind === 'local') {
return
}
before()
const subscription = subscribe()
await fail()
await vi.advanceTimersByTimeAsync(60_000)
expect(status).toEqual({ state: 'lost' })
expect(host.streams.length).toBeLessThanOrEqual(1)
expect(host.streams.every((stream) => stream.unsubscribed)).toBe(true)
subscription.close()
expect(status).toEqual({ state: 'lost' })
})
it.each([
[
'method_not_found',
@@ -388,10 +348,7 @@ describe.each(targets)(
await vi.advanceTimersByTimeAsync(999)
expect(host.streams).toHaveLength(4)
await vi.advanceTimersByTimeAsync(1)
expect(host.streams).toHaveLength(target.kind === 'local' ? 5 : 4)
if (target.kind !== 'local') {
return
}
expect(host.streams).toHaveLength(5)
// Still failing: the reopened stream's snapshot keeps the backoff growing.
const fifth = await opened(5)
fifth.send(snapshot('epoch-1', 3, ['a']))
@@ -412,9 +369,6 @@ describe.each(targets)(
})
it('starts the backoff over after one resync, once the stream is healthy again', async () => {
if (target.kind !== 'local') {
return
}
const first = await opened(1)
first.send(snapshot('epoch-1', 3, ['a']))
first.send(regrouped('epoch-1', 5, 'a', ['t1']))
@@ -467,7 +421,7 @@ describe.each(targets)(
}
)
describe('the local layout stream after a loss', () => {
describe.each(targets)('the %s layout stream after a loss', (_name, target) => {
let host: FakeHost
let cache: WorkspaceLayoutCache
let statuses: RuntimeLayoutStreamStatus[]
@@ -475,7 +429,7 @@ describe('the local layout stream after a loss', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
host = installFakeHost({ kind: 'local' })
host = installFakeHost(target)
cache = EMPTY_WORKSPACE_LAYOUT_CACHE
statuses = []
})
@@ -488,7 +442,7 @@ describe('the local layout stream after a loss', () => {
const subscribe = () =>
subscribeRuntimeLayout(
{ kind: 'local' },
target,
{},
(frame) => {
cache = applyLayoutFrame(cache, frame)
@@ -550,6 +504,15 @@ describe('the local layout stream after a loss', () => {
expect(host.streams).toHaveLength(2)
})
it('reopens after an error frame', async () => {
subscribe()
;(await opened(1)).fail('stream failed')
expect(statuses.at(-1)).toEqual({ state: 'lost' })
await vi.advanceTimersByTimeAsync(1_000)
;(await opened(2)).send(snapshot([{ key: 'a', layout: layout('a') }]))
expect(statuses.at(-1)).toEqual({ state: 'live' })
})
it('never reopens after a refusal', async () => {
subscribe()
;(await opened(1)).fail('no layout stream', 'method_not_found')
@@ -23,16 +23,15 @@ export type RuntimeLayoutStreamStatus =
// An older host without the method, or a caller scope denied it: a retry gets the same answer.
const REFUSAL_CODES = ['method_not_found', 'forbidden'] as const
/** First wait before a lost local stream reopens; it doubles to the cap until a snapshot comes. */
const LOCAL_REOPEN_DELAY_MS = 1_000
const LOCAL_REOPEN_DELAY_CAP_MS = 30_000
/** First wait before a lost stream reopens; it doubles to the cap until a snapshot comes. */
const REOPEN_DELAY_MS = 1_000
const REOPEN_DELAY_CAP_MS = 30_000
/** Immediate resubscribes allowed before a frame applies cleanly; then the loss path's backoff. */
const RESYNC_BUDGET = 3
/**
* Opens the stream; `onFrame` gets a snapshot first, then that stream's changes. A lost local
* stream reports `lost`, then reopens, and its snapshot replaces what the caller holds. A remote
* stream that drops reports `lost`; reopening it belongs to the connection's reconnect.
* Opens the stream; `onFrame` gets a snapshot first, then that stream's changes. A lost stream
* reports `lost`, then reopens after a backoff, and its snapshot replaces what the caller holds.
*
* A live stream that misses a frame resubscribes at once for a fresh snapshot: a records frame of
* another epoch or out of sequence, an unreadable frame, or one `onFrame` cannot apply.
@@ -51,7 +50,7 @@ export function subscribeRuntimeLayout(
let handle: { unsubscribe: () => void } | null = null
let state: RuntimeLayoutStreamStatus['state'] = 'connecting'
let reopenTimer: ReturnType<typeof setTimeout> | null = null
let reopenDelay = LOCAL_REOPEN_DELAY_MS
let reopenDelay = REOPEN_DELAY_MS
/** The snapshot's epoch and the last position this stream applied, once a host sends epochs. */
let epoch: string | undefined
let lastSeq: number | undefined
@@ -85,14 +84,14 @@ export function subscribeRuntimeLayout(
}
const refusal = REFUSAL_CODES.find((code) => hasRuntimeRpcErrorCode(error, code))
stop(refusal ? { state: 'refused', code: refusal } : { state: 'lost' })
// Why: layout.apply still reaches main without the stream, so only main's own changes would
// go unseen until a relaunch.
if (!refusal && target.kind === 'local') {
// Why: layout.apply still reaches main without the stream, so main's own changes would go
// unseen until a relaunch; a server's would until its connection is rebuilt.
if (!refusal) {
reopenTimer = setTimeout(() => {
reopenTimer = null
void open()
}, reopenDelay)
reopenDelay = Math.min(reopenDelay * 2, LOCAL_REOPEN_DELAY_CAP_MS)
reopenDelay = Math.min(reopenDelay * 2, REOPEN_DELAY_CAP_MS)
}
}
@@ -154,7 +153,7 @@ export function subscribeRuntimeLayout(
if (frame.type === 'snapshot') {
// During a resync storm the backoff keeps growing; a snapshot alone proves nothing.
if (!storm) {
reopenDelay = LOCAL_REOPEN_DELAY_MS
reopenDelay = REOPEN_DELAY_MS
}
epoch = frame.epoch
lastSeq = frame.seq
@@ -177,7 +176,7 @@ export function subscribeRuntimeLayout(
resyncs = 0
if (storm) {
storm = false
reopenDelay = LOCAL_REOPEN_DELAY_MS
reopenDelay = REOPEN_DELAY_MS
}
return
}
@@ -0,0 +1,243 @@
import { describe, expect, it } from 'vitest'
import {
toRemoteRuntimePtyId,
parseRemoteRuntimePtyId
} from '../../../shared/remote-runtime-pty-id'
import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
import { ownedEditorFileId } from '../../../shared/workspace-session-pane-ownership'
import type {
PublishedTab,
PublishedWorkspaceLayout
} from '../../../shared/workspace-layout/workspace-layout-published'
import {
createServerLayoutFrameTranslator,
desktopLayoutForServer,
serverLayoutForDesktop,
type ServerTerminalHandleOf
} from './server-layout-frame-ids'
const ENV = 'env-1'
const WT = 'repo-1::/srv/app'
const OWNED = ownedEditorFileId('/srv/app/b.ts', WT, undefined)
function tab(fields: Partial<PublishedTab> & Pick<PublishedTab, 'id' | 'kind'>): PublishedTab {
return {
entityId: fields.id,
executionHostId: 'local',
createdAt: 1,
customTitle: null,
color: null,
...fields
}
}
function terminal(id: string, entityId: string, leaves: [string, string?][]): PublishedTab {
return tab({
id,
entityId,
kind: 'terminal',
terminal: {
root: { type: 'leaf', leafId: leaves[0]![0] },
panes: leaves.map(([leafId, ptyId]) => ({ leafId, ...(ptyId ? { ptyId } : {}) }))
}
})
}
/** A server's workspace with every id kind: terminals (one with its own tab-bar id), editors by
* path and by owned id, a browser tab and a chat. */
function serverLayout(): PublishedWorkspaceLayout {
return {
worktreeId: WT,
groups: [
{ id: 'g-1', tabIds: ['tab-1', 'entry-2', '/srv/app/a.ts', 'ed-2', 'br-1', 'chat-1'] }
],
tabs: [
terminal('tab-1', 'tab-1', [['leaf-1', 'pty-1'], ['leaf-2']]),
terminal('entry-2', 'term-2', [['leaf-3', 'pty-3']]),
tab({ id: '/srv/app/a.ts', kind: 'editor' }),
tab({ id: 'ed-2', entityId: OWNED, kind: 'editor' }),
tab({ id: 'br-1', entityId: 'browser-1', kind: 'browser' }),
tab({ id: 'chat-1', entityId: 'session-1', kind: 'agent-session' })
],
editorFiles: [
{ filePath: '/srv/app/a.ts', relativePath: 'a.ts', language: 'typescript' },
{
filePath: '/srv/app/b.ts',
relativePath: 'b.ts',
language: 'typescript',
externalSshTargetId: 'servers-own-target'
}
],
browserTabs: [{ id: 'browser-1', createdAt: 1 }]
}
}
/** session.tabs' handles: one per bound pane, named after its host surface. */
const handles: ServerTerminalHandleOf = (hostTabId, leafId) => `term_${hostTabId}_${leafId}`
describe('a server layout under desktop ids', () => {
const desktop = serverLayoutForDesktop(serverLayout(), ENV, handles)
it('names each id the way the session.tabs mirror names it', () => {
expect(desktop.groups[0]!.tabIds).toEqual([
toWebTerminalSurfaceTabId('tab-1'),
toWebTerminalSurfaceTabId('term-2'),
'/srv/app/a.ts',
'ed-2',
'br-1',
'chat-1'
])
expect(desktop.tabs.map((entry) => [entry.id, entry.entityId])).toEqual([
[toWebTerminalSurfaceTabId('tab-1'), toWebTerminalSurfaceTabId('tab-1')],
[toWebTerminalSurfaceTabId('term-2'), toWebTerminalSurfaceTabId('term-2')],
['/srv/app/a.ts', '/srv/app/a.ts'],
['ed-2', ownedEditorFileId('/srv/app/b.ts', WT, ENV)],
['br-1', 'browser-1'],
['chat-1', 'session-1']
])
expect(new Set(desktop.tabs.map((entry) => entry.executionHostId))).toEqual(
new Set(['runtime:env-1'])
)
expect(desktop.editorFiles).toEqual([
{
filePath: '/srv/app/a.ts',
relativePath: 'a.ts',
language: 'typescript',
runtimeEnvironmentId: ENV
},
{
filePath: '/srv/app/b.ts',
relativePath: 'b.ts',
language: 'typescript',
runtimeEnvironmentId: ENV
}
])
expect(desktop.browserTabs).toEqual(serverLayout().browserTabs)
})
it('binds a pane to its session.tabs handle, and a bound pane without one stays pending', () => {
const panes = desktop.tabs[0]!.terminal!.panes
expect(panes[0]).toEqual({
leafId: 'leaf-1',
ptyId: toRemoteRuntimePtyId('term_tab-1_leaf-1', ENV)
})
expect(panes[1]).toEqual({ leafId: 'leaf-2' })
const pending = serverLayoutForDesktop(serverLayout(), ENV, () => null)
expect(pending.tabs[0]!.terminal!.panes[0]).toEqual({ leafId: 'leaf-1' })
})
it('maps every id kind desktop to server to desktop as the identity', () => {
const handleOfPtyId: ServerTerminalHandleOf = (hostTabId, leafId) => {
const pane = desktop.tabs
.find((entry) => entry.entityId === toWebTerminalSurfaceTabId(hostTabId))
?.terminal?.panes.find((candidate) => candidate.leafId === leafId)
return pane?.ptyId ? parseRemoteRuntimePtyId(pane.ptyId)!.handle : null
}
// The server's own panes are bound wherever the desktop's are.
const server = desktopLayoutForServer(desktop)
const boundServer = {
...server,
tabs: server.tabs.map((entry) =>
entry.terminal
? {
...entry,
terminal: {
...entry.terminal,
panes: entry.terminal.panes.map((pane) =>
handleOfPtyId(entry.entityId, pane.leafId) ? { ...pane, ptyId: 'bound' } : pane
)
}
}
: entry
)
}
expect(serverLayoutForDesktop(boundServer, ENV, handleOfPtyId)).toEqual(desktop)
})
it('refuses a tab kind it does not know rather than show it under raw ids', () => {
const layout = serverLayout()
// Through JSON, as the wire delivers it: no TabContentType names this kind.
const future: PublishedTab = JSON.parse(
JSON.stringify({ ...layout.tabs[2]!, id: 'future-1', kind: 'hologram' })
)
layout.tabs.push(future)
expect(() => serverLayoutForDesktop(layout, ENV, handles)).toThrow('unknown tab kind')
})
})
describe('one server stream translated in order', () => {
it('re-diffs a records frame under desktop ids, and the same frame twice gives the same ids', () => {
const before = serverLayout()
const after = structuredClone(before)
after.groups[0]!.tabIds.reverse()
const translator = createServerLayoutFrameTranslator(ENV, () => handles)
translator.translate({
type: 'snapshot',
workspaces: [{ key: WT, layout: before }],
seq: 1,
epoch: 'e'
})
const frame = translator.translate({
type: 'records',
epoch: 'e',
seq: 2,
workspaces: [
{ key: WT, change: 'records', workspace: { worktreeId: WT, groups: after.groups } }
]
})
expect(frame).toMatchObject({
type: 'records',
seq: 2,
workspaces: [
{
key: WT,
change: 'records',
workspace: {
groups: [
{ id: 'g-1', tabIds: serverLayoutForDesktop(after, ENV, handles).groups[0]!.tabIds }
]
}
}
]
})
const again = createServerLayoutFrameTranslator(ENV, () => handles)
again.translate({
type: 'snapshot',
workspaces: [{ key: WT, layout: after }],
seq: 2,
epoch: 'e'
})
expect(again.rebind(WT)).toBeNull()
})
it('rebinds only the panes whose session.tabs handle changed', () => {
let known = new Map<string, string>()
const translator = createServerLayoutFrameTranslator(
ENV,
() => (hostTabId, leafId) => known.get(`${hostTabId}/${leafId}`) ?? null
)
translator.translate({
type: 'snapshot',
workspaces: [{ key: WT, layout: serverLayout() }],
seq: 4,
epoch: 'e'
})
expect(translator.rebind(WT)).toBeNull()
known = new Map([['tab-1/leaf-1', 'term_a']])
expect(translator.rebind(WT)).toEqual({
type: 'records',
epoch: 'e',
seq: 4,
workspaces: [
{
key: WT,
change: 'records',
bindings: {
set: [{ leafId: 'leaf-1', ptyId: toRemoteRuntimePtyId('term_a', ENV) }]
}
}
]
})
expect(translator.rebind(WT)).toBeNull()
})
})
@@ -0,0 +1,297 @@
// A paired server's layout frames under the ids this desktop already gives that server's tabs (the
// session.tabs mirror's), so a workspace moves between the two sources without a remount. The one
// place server ids become desktop ids; layout.apply maps back through the same rules. Frames carry
// no terminal handle: a pane binds the handle session.tabs gives its (host tab, leaf) surface.
import {
LOCAL_EXECUTION_HOST_ID,
toRuntimeExecutionHostId,
type ExecutionHostId
} from '../../../shared/execution-host'
import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id'
import type { TabContentType } from '../../../shared/tab-types'
import {
isWebTerminalSurfaceTabId,
toHostSessionTabId,
toWebTerminalSurfaceTabId
} from '../../../shared/terminal-surface-id'
import { ownedEditorFileId } from '../../../shared/workspace-session-pane-ownership'
import type {
PublishedEditorFile,
PublishedPane,
PublishedTab,
PublishedWorkspaceLayout
} from '../../../shared/workspace-layout/workspace-layout-published'
import {
applyWorkspaceLayoutRecordChange,
diffPublishedLayouts,
indexPublishedLayout,
type IndexedLayoutRecords,
type WorkspaceLayoutRecordChange
} from '../../../shared/workspace-layout/workspace-layout-records'
import type {
WorkspaceLayoutChangeFrame,
WorkspaceLayoutRecordsFrame
} from '../../../shared/workspace-layout/workspace-layout-stream-frames'
/** Which tab ids a kind changes: a terminal's take the mirror's surface prefix, others keep theirs. */
const TAB_ID_RULES: Record<TabContentType, 'terminal' | 'kept'> = {
terminal: 'terminal',
editor: 'kept',
diff: 'kept',
'conflict-review': 'kept',
'check-details': 'kept',
'agent-session': 'kept',
browser: 'kept',
simulator: 'kept'
}
function isKnownTabKind(kind: string): kind is TabContentType {
return Object.hasOwn(TAB_ID_RULES, kind)
}
function tabIdRule(kind: string): 'terminal' | 'kept' {
const rule = isKnownTabKind(kind) ? TAB_ID_RULES[kind] : undefined
if (!rule) {
// A newer server's tab kind: drawing it under raw ids would mix two id spaces.
throw new Error(`server layout: unknown tab kind ${kind}`)
}
return rule
}
/** An editor tab names its file by path, or by the owned id of the server's own (local) file. */
function desktopEditorEntityId(
entityId: string,
layout: PublishedWorkspaceLayout,
environmentId: string
): string {
const file = layout.editorFiles.find(
(candidate) =>
ownedEditorFileId(candidate.filePath, layout.worktreeId, candidate.runtimeEnvironmentId) ===
entityId
)
return file ? ownedEditorFileId(file.filePath, layout.worktreeId, environmentId) : entityId
}
/** The handle session.tabs gives a server terminal tab's pane; null while it has none. */
export type ServerTerminalHandleOf = (hostTabId: string, leafId: string) => string | null
function desktopPane(
pane: PublishedPane,
hostTabId: string,
environmentId: string,
handleOf: ServerTerminalHandleOf
): PublishedPane {
const { ptyId, ptyGeneration, ...rest } = pane
const handle = ptyId ? handleOf(hostTabId, pane.leafId) : null
// A bound pane without a handle yet shows as pending, as the mirror's `pending-handle` does.
return handle
? {
...rest,
ptyId: toRemoteRuntimePtyId(handle, environmentId),
...(ptyGeneration !== undefined ? { ptyGeneration } : {})
}
: rest
}
function desktopTab(
tab: PublishedTab,
layout: PublishedWorkspaceLayout,
host: ExecutionHostId,
environmentId: string,
handleOf: ServerTerminalHandleOf
): PublishedTab {
const terminal = tabIdRule(tab.kind) === 'terminal'
return {
...tab,
// The mirror names a terminal tab by its terminal's id alone, tab-bar entry included.
id: terminal ? toWebTerminalSurfaceTabId(tab.entityId) : tab.id,
entityId: terminal
? toWebTerminalSurfaceTabId(tab.entityId)
: tab.kind === 'editor'
? desktopEditorEntityId(tab.entityId, layout, environmentId)
: tab.entityId,
executionHostId: host,
...(tab.terminal
? {
terminal: {
...tab.terminal,
panes: tab.terminal.panes.map((pane) =>
desktopPane(pane, tab.entityId, environmentId, handleOf)
)
}
}
: {})
}
}
function desktopEditorFile(file: PublishedEditorFile, environmentId: string): PublishedEditorFile {
// The server's SSH target names a host of the server's, never one of this desktop's.
const { externalSshTargetId: _serverTarget, ...rest } = file
return { ...rest, runtimeEnvironmentId: environmentId }
}
/** One workspace of a server's frames under this desktop's ids. */
export function serverLayoutForDesktop(
layout: PublishedWorkspaceLayout,
environmentId: string,
handleOf: ServerTerminalHandleOf
): PublishedWorkspaceLayout {
const host = toRuntimeExecutionHostId(environmentId)
const terminalEntityByTabId = new Map(
layout.tabs
.filter((tab) => tabIdRule(tab.kind) === 'terminal')
.map((tab) => [tab.id, tab.entityId])
)
const tabId = (id: string) => {
const entityId = terminalEntityByTabId.get(id)
return entityId === undefined ? id : toWebTerminalSurfaceTabId(entityId)
}
return {
...layout,
groups: layout.groups.map((group) => ({ ...group, tabIds: group.tabIds.map(tabId) })),
tabs: layout.tabs.map((tab) => desktopTab(tab, layout, host, environmentId, handleOf)),
editorFiles: layout.editorFiles.map((file) => desktopEditorFile(file, environmentId)),
browserTabs: layout.browserTabs
}
}
/** A desktop tab id as the server knows it. */
export function serverTabIdOf(desktopTabId: string): string {
return isWebTerminalSurfaceTabId(desktopTabId) ? toHostSessionTabId(desktopTabId) : desktopTabId
}
/** A desktop editor tab's file id as the server knows it: its own file, owned by no environment. */
function serverEditorEntityId(entityId: string, layout: PublishedWorkspaceLayout): string {
const file = layout.editorFiles.find(
(candidate) =>
ownedEditorFileId(candidate.filePath, layout.worktreeId, candidate.runtimeEnvironmentId) ===
entityId
)
return file ? ownedEditorFileId(file.filePath, layout.worktreeId, undefined) : entityId
}
/**
* A desktop-shaped workspace under the server's ids: the inverse of `serverLayoutForDesktop` for
* every id it maps. Panes keep no terminal: the desktop binds handles, which the server never takes.
*/
export function desktopLayoutForServer(layout: PublishedWorkspaceLayout): PublishedWorkspaceLayout {
return {
...layout,
groups: layout.groups.map((group) => ({ ...group, tabIds: group.tabIds.map(serverTabIdOf) })),
tabs: layout.tabs.map((tab) => {
tabIdRule(tab.kind)
return {
...tab,
id: serverTabIdOf(tab.id),
entityId:
tab.kind === 'editor'
? serverEditorEntityId(tab.entityId, layout)
: serverTabIdOf(tab.entityId),
executionHostId: LOCAL_EXECUTION_HOST_ID,
...(tab.terminal
? {
terminal: {
...tab.terminal,
panes: tab.terminal.panes.map(
({ ptyId: _handle, ptyGeneration: _g, ...pane }) => pane
)
}
}
: {})
}
}),
editorFiles: layout.editorFiles.map(({ runtimeEnvironmentId: _env, ...file }) => file)
}
}
/** One server stream's frames under desktop ids, and the changes a new terminal handle makes. */
export type ServerLayoutFrameTranslator = {
translate: (frame: WorkspaceLayoutChangeFrame) => WorkspaceLayoutChangeFrame
/** A records frame binding the panes whose handle session.tabs changed; null when none did. */
rebind: (key: string) => WorkspaceLayoutRecordsFrame | null
/** The workspaces the server's stream holds now. */
keys: () => string[]
}
/**
* Translates one server stream's frames in order. It keeps the server's copy of each workspace, so
* a records frame is re-diffed under desktop ids: a record's key can depend on another record (a
* group names terminal tabs by their kind), which one frame alone may not carry.
*/
export function createServerLayoutFrameTranslator(
environmentId: string,
handleOf: (worktreeId: string) => ServerTerminalHandleOf
): ServerLayoutFrameTranslator {
const server = new Map<string, PublishedWorkspaceLayout>()
const desktop = new Map<string, Translated>()
/** The last server frame's stream identity, which a desktop-made frame repeats. */
let position = { epoch: '', seq: 0 }
const show = (key: string, layout: PublishedWorkspaceLayout): Translated => {
const translated = serverLayoutForDesktop(layout, environmentId, handleOf(layout.worktreeId))
const entry = { layout: translated, index: indexPublishedLayout(translated) }
server.set(key, layout)
desktop.set(key, entry)
return entry
}
const drop = (key: string): void => {
server.delete(key)
desktop.delete(key)
}
const changeTo = (
key: string,
layout: PublishedWorkspaceLayout
): WorkspaceLayoutRecordChange[] => {
const before = desktop.get(key)
const after = show(key, layout)
const diff = diffPublishedLayouts(key, before?.index ?? null, after.index, after.layout)
return diff ? [diff] : []
}
const recordChange = (change: WorkspaceLayoutRecordChange): WorkspaceLayoutRecordChange[] => {
if (change.change === 'removed') {
drop(change.key)
return [change]
}
if (change.change === 'layout') {
return [{ ...change, layout: show(change.key, change.layout).layout }]
}
// Throws for a change that does not fit: the stream then resubscribes for a snapshot.
return changeTo(change.key, applyWorkspaceLayoutRecordChange(server.get(change.key), change)!)
}
return {
translate: (frame) => {
switch (frame.type) {
case 'snapshot':
server.clear()
desktop.clear()
position = { epoch: frame.epoch ?? '', seq: frame.seq ?? 0 }
return {
...frame,
workspaces: frame.workspaces.map(({ key, layout }) => ({
key,
layout: show(key, layout).layout
}))
}
case 'workspace':
return { ...frame, layout: show(frame.key, frame.layout).layout }
case 'removed':
drop(frame.key)
return frame
case 'records':
position = { epoch: frame.epoch, seq: frame.seq }
return { ...frame, workspaces: frame.workspaces.flatMap(recordChange) }
}
},
keys: () => [...server.keys()],
rebind: (key) => {
const layout = server.get(key)
const workspaces = layout ? changeTo(key, layout) : []
return workspaces.length > 0 ? { type: 'records', ...position, workspaces } : null
}
}
}
type Translated = { layout: PublishedWorkspaceLayout; index: IndexedLayoutRecords | null }
@@ -0,0 +1,368 @@
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import type * as AgentStatusModule from '@/lib/agent-status'
import type * as FrameDrawnModule from './web-session-tabs-sync/frame-drawn-server-workspaces'
import type { AppState } from '@/store/types'
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
import { toRuntimeExecutionHostId } from '../../../shared/execution-host'
import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id'
import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load'
import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids'
import {
publishWorkspaceLayout,
type PublishedWorkspaceLayout
} from '../../../shared/workspace-layout/workspace-layout-published'
import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness'
import { _resetLayoutFramePositionForTests } from './layout-frame-position'
import { mainLayoutFrameSender } from './main-loaded-layout-frames.test-fixture'
import { serverLayoutForDesktop } from './server-layout-frame-ids'
import {
CAPABLE,
ENV,
LEAF_ID,
WT,
SECOND_LEAF_ID,
SERVER_LAYOUT,
createFakeLayoutStreams,
createServerHarness,
mirrorSnapshot,
serverLayout,
serverTerminalTab,
senderSnapshot,
sessionTabs,
settle,
surface,
treeOf,
type TestStore
} from './server-layout-frames.test-fixture'
import { installLayoutFrameApplyCheck } from './workspace-layout-frame-apply-check.test-fixture'
import {
isFrameDrawnServerWorkspace,
resetFrameDrawnServerWorkspacesForTests
} from './web-session-tabs-sync/frame-drawn-server-workspaces'
import { resetMirroredTerminalHandlesForTests } from './web-session-tabs-sync/mirrored-terminal-handle-index'
import { resetWebSessionTabsSyncTestState } from './web-session-tabs-sync-test-harness'
type Subscribe = (...args: never[]) => { close: () => void }
const hoisted = vi.hoisted(
(): {
owners: Map<string, string>
resync: Mock<(environmentId: string, worktreeId: string) => Promise<void>>
subscribe: Subscribe | null
} => ({
owners: new Map(),
resync: vi.fn(),
subscribe: null
})
)
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
vi.mock('@/lib/agent-status', async (importOriginal) => {
const actual = await importOriginal<typeof AgentStatusModule>()
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
})
vi.mock('./runtime-layout-client', () => ({
subscribeRuntimeLayout: (...args: never[]) => hoisted.subscribe!(...args)
}))
vi.mock('./web-session-tabs-sync/mirrored-workspace-owner', () => ({
mirroredRuntimeEnvironmentIdForWorkspace: (_state: unknown, key: string) =>
hoisted.owners.get(key) ?? null
}))
// Why not mock its RPC instead: vitest gives concurrent dynamic imports of a mocked module the real one.
vi.mock('./web-session-tabs-sync/frame-drawn-server-workspaces', async (importOriginal) => ({
...(await importOriginal<typeof FrameDrawnModule>()),
resyncMirroredServerWorkspace: hoisted.resync
}))
createStoreSessionMockApi()
const streams = createFakeLayoutStreams()
hoisted.subscribe = streams.subscribe
const TAB_1 = toWebTerminalSurfaceTabId('host-tab-1')
const TAB_2 = toWebTerminalSurfaceTabId('host-tab-2')
const REMOTE_1 = toRemoteRuntimePtyId('terminal-1', ENV)
const { setServers, mirroredStore, install, uninstallAll } = createServerHarness(hoisted)
/** The server's layout once it opened a second terminal tab, bound on the server. */
const WITH_TAB_2 = serverLayout([
serverTerminalTab('host-tab-1', LEAF_ID, 'server-pty-1'),
serverTerminalTab('host-tab-2', SECOND_LEAF_ID, 'server-pty-2', 2)
])
/** The workspace as the store shows it, in the published shape the frames translate to. */
function publishedStoreTree(state: AppState): PublishedWorkspaceLayout {
const host = toRuntimeExecutionHostId(ENV)
const { layout } = loadWorkspaceLayoutWithoutReport(
host,
buildWorkspaceSessionPayload(state),
nameBasedLoadContext()
)
return publishWorkspaceLayout(layout.workspaces[WT]!, host)
}
/** Every state the store passes through, from now until `stop`. */
function recordStates(store: TestStore) {
const states: AppState[] = [store.getState()]
const stop = store.subscribe((state) => states.push(state))
return { states, stop }
}
/** A mirrored store whose WT the server's stream now draws, shown from its snapshot. */
async function frameDrawnStore(store = mirroredStore()) {
install(store)
setServers(store, [CAPABLE])
const stream = streams.openTo(ENV)
const frames = mainLayoutFrameSender()
streams.send(stream, senderSnapshot(frames, { [WT]: SERVER_LAYOUT }, 1))
await settle()
return { store, stream, frames }
}
const bindingOf = (state: AppState, tabId: string, leafId: string) =>
state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId]
/** How many times a pane's binding changed across the recorded states. */
function bindingChanges(states: AppState[], tabId: string, leafId: string): number {
return states.filter(
(state, index) =>
index > 0 && bindingOf(state, tabId, leafId) !== bindingOf(states[index - 1]!, tabId, leafId)
).length
}
let check: ReturnType<typeof installLayoutFrameApplyCheck>
beforeEach(() => {
hoisted.owners.clear()
hoisted.resync.mockReset().mockResolvedValue(undefined)
streams.all.length = 0
resetWebSessionTabsSyncTestState()
resetFrameDrawnServerWorkspacesForTests()
resetMirroredTerminalHandlesForTests()
_resetLayoutFramePositionForTests()
check = installLayoutFrameApplyCheck()
})
afterEach(() => {
uninstallAll()
check.uninstall()
expect(check.failures).toEqual([])
})
describe('mirror to frames', () => {
it('gates the workspace before the snapshot shows, then shows it with no empty state and no reattach', async () => {
const store = mirroredStore()
const mirrorTree = treeOf(store.getState(), WT)
expect(mirrorTree.bindings).toEqual({ [TAB_1]: { [LEAF_ID]: REMOTE_1 } })
// The server's snapshot translates to exactly the tree the mirror drew.
const handleOf = (hostTabId: string, leafId: string) =>
hostTabId === 'host-tab-1' && leafId === LEAF_ID ? 'terminal-1' : null
expect(serverLayoutForDesktop(SERVER_LAYOUT, ENV, handleOf)).toEqual(
publishedStoreTree(store.getState())
)
install(store)
setServers(store, [CAPABLE])
const stream = streams.openTo(ENV)
const recorded = recordStates(store)
streams.send(stream, {
type: 'snapshot',
workspaces: [{ key: WT, layout: SERVER_LAYOUT }],
seq: 1,
epoch: 'e'
})
// Before the frame's flush: the gate is up and the store still shows the mirror's tree.
expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(true)
expect(check.fullBuilt).toEqual([])
expect(treeOf(store.getState(), WT)).toEqual(mirrorTree)
await settle()
recorded.stop()
// Presence: the frames built the workspace; the same tree needed no store write at all.
expect(check.fullBuilt).toEqual([WT])
expect(recorded.states).toHaveLength(1)
expect(treeOf(store.getState(), WT)).toEqual(mirrorTree)
})
it('writes what the frames change over the mirror’s tree without emptying it between', async () => {
const store = mirroredStore()
const mirrorTree = treeOf(store.getState(), WT)
expect(mirrorTree.tabIds).toEqual([TAB_1])
install(store)
setServers(store, [CAPABLE])
const recorded = recordStates(store)
const renamed = structuredClone(SERVER_LAYOUT)
renamed.tabs[0]!.customTitle = 'named on the server'
streams.send(streams.openTo(ENV), {
type: 'snapshot',
workspaces: [{ key: WT, layout: renamed }],
seq: 1,
epoch: 'e'
})
expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(true)
await settle()
recorded.stop()
expect(store.getState().tabsByWorktree[WT]?.[0]?.customTitle).toBe('named on the server')
expect(recorded.states.length).toBeGreaterThan(1)
for (const state of recorded.states) {
expect(treeOf(state, WT)).toEqual(mirrorTree)
}
})
})
describe('a terminal handle and the frame that binds its pane, in either order', () => {
it('shows the pane pending until session.tabs has its handle, then binds it once in place', async () => {
const { store, stream, frames } = await frameDrawnStore()
streams.send(stream, frames.records(WT, WITH_TAB_2))
await settle()
const tabsBefore = (store.getState().tabsByWorktree[WT] ?? []).map((tab) => tab.id)
expect(tabsBefore).toEqual([TAB_1, TAB_2])
const pending = store.getState().terminalLayoutsByTabId[TAB_2]
expect(pending?.root).toEqual({ type: 'leaf', leafId: SECOND_LEAF_ID })
expect(pending?.ptyIdsByLeafId ?? {}).not.toHaveProperty(SECOND_LEAF_ID)
const recorded = recordStates(store)
mirrorSnapshot(
store,
ENV,
sessionTabs(
[
surface('host-tab-1', LEAF_ID, 'terminal-1'),
surface('host-tab-2', SECOND_LEAF_ID, 'terminal-2')
],
2
)
)
await settle()
recorded.stop()
const bound = toRemoteRuntimePtyId('terminal-2', ENV)
expect(bindingOf(store.getState(), TAB_2, SECOND_LEAF_ID)).toBe(bound)
expect(bindingChanges(recorded.states, TAB_2, SECOND_LEAF_ID)).toBe(1)
for (const state of recorded.states) {
expect((state.tabsByWorktree[WT] ?? []).map((tab) => tab.id)).toEqual(tabsBefore)
expect(state.terminalLayoutsByTabId[TAB_2]?.root).toEqual(pending?.root)
}
// The other pane kept its binding throughout.
expect(bindingChanges(recorded.states, TAB_1, LEAF_ID)).toBe(0)
expect(bindingOf(store.getState(), TAB_1, LEAF_ID)).toBe(REMOTE_1)
})
it('binds the pane in the frame’s own flush when session.tabs had its handle first', async () => {
const { store, stream, frames } = await frameDrawnStore()
mirrorSnapshot(
store,
ENV,
sessionTabs(
[
surface('host-tab-1', LEAF_ID, 'terminal-1'),
surface('host-tab-2', SECOND_LEAF_ID, 'terminal-2')
],
2
)
)
await settle()
// The gated mirror drew no tab of its own for the handle.
expect(store.getState().terminalLayoutsByTabId[TAB_2]).toBeUndefined()
const recorded = recordStates(store)
streams.send(stream, frames.records(WT, WITH_TAB_2))
await settle()
recorded.stop()
const bound = toRemoteRuntimePtyId('terminal-2', ENV)
expect(bindingOf(store.getState(), TAB_2, SECOND_LEAF_ID)).toBe(bound)
// The first state holding the tab already holds its binding.
const firstShown = recorded.states.find((state) => state.terminalLayoutsByTabId[TAB_2])
expect(bindingOf(firstShown!, TAB_2, SECOND_LEAF_ID)).toBe(bound)
expect(bindingChanges(recorded.states, TAB_2, SECOND_LEAF_ID)).toBe(1)
})
it('rebinds a pane that stays bound exactly once when its handle is replaced', async () => {
const { store } = await frameDrawnStore()
expect(bindingOf(store.getState(), TAB_1, LEAF_ID)).toBe(REMOTE_1)
const recorded = recordStates(store)
mirrorSnapshot(store, ENV, sessionTabs([surface('host-tab-1', LEAF_ID, 'terminal-1b')], 2))
await settle()
recorded.stop()
expect(bindingOf(store.getState(), TAB_1, LEAF_ID)).toBe(
toRemoteRuntimePtyId('terminal-1b', ENV)
)
expect(bindingChanges(recorded.states, TAB_1, LEAF_ID)).toBe(1)
for (const state of recorded.states) {
expect((state.tabsByWorktree[WT] ?? []).map((tab) => tab.id)).toEqual([TAB_1])
}
})
})
describe('a workspace handed back to the mirror', () => {
it('is not drawn from the stopped stream’s frames while the stream reconnects', async () => {
const other = 'repo::/other-worktree'
hoisted.owners.set(other, ENV)
const store = mirroredStore()
install(store)
setServers(store, [CAPABLE])
const stream = streams.openTo(ENV)
const frames = mainLayoutFrameSender()
const otherLayout = {
...serverLayout([serverTerminalTab('host-tab-9', LEAF_ID, 'server-pty-9')]),
worktreeId: other
}
streams.send(stream, senderSnapshot(frames, { [WT]: SERVER_LAYOUT, [other]: otherLayout }, 1))
await settle()
stream.onStatus({ state: 'lost' })
stream.onStatus({ state: 'connecting' })
await settle()
// The mirror's first snapshot of the other workspace, after it took it back.
mirrorSnapshot(store, ENV, {
...sessionTabs([surface('host-tab-8', LEAF_ID, 'terminal-8')], 1),
worktree: other
})
await settle()
expect((store.getState().tabsByWorktree[other] ?? []).map((tab) => tab.id)).toEqual([
toWebTerminalSurfaceTabId('host-tab-8')
])
})
})
describe('two desktop windows', () => {
it('bind identical ptyIds from the same server frames and handles', async () => {
const first = mirroredStore()
const second = mirroredStore()
install(first)
install(second)
setServers(first, [CAPABLE])
setServers(second, [CAPABLE])
const open = streams.of(ENV).filter((stream) => !stream.closed)
expect(open).toHaveLength(2)
for (const stream of open) {
const frames = mainLayoutFrameSender()
streams.send(stream, senderSnapshot(frames, { [WT]: SERVER_LAYOUT }, 1))
streams.send(stream, frames.records(WT, WITH_TAB_2))
}
await settle()
const handles = sessionTabs(
[
surface('host-tab-1', LEAF_ID, 'terminal-1'),
surface('host-tab-2', SECOND_LEAF_ID, 'terminal-2')
],
2
)
mirrorSnapshot(first, ENV, handles)
mirrorSnapshot(second, ENV, handles)
await settle()
const expected = {
[TAB_1]: { [LEAF_ID]: REMOTE_1 },
[TAB_2]: { [SECOND_LEAF_ID]: toRemoteRuntimePtyId('terminal-2', ENV) }
}
expect(treeOf(first.getState(), WT).bindings).toEqual(expected)
expect(treeOf(second.getState(), WT)).toEqual(treeOf(first.getState(), WT))
})
})
@@ -0,0 +1,303 @@
// A paired server for the server layout frame tests: its session.tabs snapshots, its layout in the
// server's own ids, its status entry, and the layout streams the desktop opens to it.
import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status'
import type { RuntimeMobileSessionTabsResult, RuntimeStatus } from '../../../shared/runtime-types'
import { LAYOUT_INTENTS_RUNTIME_CAPABILITY } from '../../../shared/workspace-layout/layout-intents-capability'
import type {
PublishedTab,
PublishedWorkspaceLayout
} from '../../../shared/workspace-layout/workspace-layout-published'
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import type { AppState } from '@/store/types'
import type { RuntimeLayoutStreamStatus } from './runtime-layout-client'
import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments'
import { createTestStore } from '../store/slices/store-test-helpers'
import { installServerLayoutFrames } from './server-layout-frames'
import { applyWebSessionTabsSnapshotWithContext } from './web-session-tabs-sync/apply-snapshot'
export const ENV = 'web-env-1'
export const WT = 'repo::/worktree'
export const NOW = 1_700_000_000_000
export const LEAF_ID = '11111111-1111-4111-8111-111111111111'
export const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
export const HOST_GROUP = 'host-group-1'
/** One host terminal surface; a null handle is session.tabs' `pending-handle`. */
export function surface(hostTabId: string, leafId: string, terminal: string | null) {
const base = {
type: 'terminal' as const,
id: `${hostTabId}::${leafId}`,
title: hostTabId,
parentTabId: hostTabId,
leafId,
isActive: hostTabId === 'host-tab-1'
}
return terminal === null
? { ...base, status: 'pending-handle' as const, terminal: null }
: { ...base, status: 'ready' as const, terminal }
}
export function sessionTabs(
surfaces: ReturnType<typeof surface>[],
snapshotVersion: number
): RuntimeMobileSessionTabsResult {
return {
worktree: WT,
publicationEpoch: 'epoch-1',
snapshotVersion,
activeGroupId: HOST_GROUP,
activeTabId: surfaces.find((tab) => tab.isActive)?.id ?? null,
activeTabType: 'terminal',
tabs: surfaces
}
}
/** A terminal tab as the server publishes it: its own ids, its own pty. */
export function serverTerminalTab(
hostTabId: string,
leafId: string,
serverPtyId: string | null,
createdAt = NOW
): PublishedTab {
return {
id: hostTabId,
kind: 'terminal',
entityId: hostTabId,
executionHostId: 'local',
createdAt,
customTitle: null,
color: null,
isPinned: false,
isPreview: false,
terminal: {
root: { type: 'leaf', leafId },
panes: [{ leafId, ...(serverPtyId ? { ptyId: serverPtyId } : {}) }],
defaultTitle: hostTabId
}
}
}
export function serverLayout(tabs: PublishedTab[]): PublishedWorkspaceLayout {
return {
worktreeId: WT,
groups: [{ id: HOST_GROUP, tabIds: tabs.map((tab) => tab.id) }],
groupLayout: { type: 'leaf', groupId: HOST_GROUP },
tabs,
editorFiles: [],
browserTabs: []
}
}
export function runtimeStatusEntry(
runtimeId: string,
capable: boolean,
disconnected = false
): RuntimeEnvironmentStatus {
const status: RuntimeStatus = {
runtimeId,
rendererGraphEpoch: 0,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0,
...(capable ? { capabilities: [LAYOUT_INTENTS_RUNTIME_CAPABILITY] } : {})
}
if (!disconnected) {
return { status, checkedAt: 1 }
}
// A retired pairing: the verified status stays, and the host reads disconnected.
return {
status,
checkedAt: 1,
snapshot: {
environmentId: 'unused',
pairingRevision: 1,
sequence: 1,
checkedAt: 1,
status,
verification: 'verified',
transport: 'ready',
retired: true
}
}
}
/** A paired server as the catalog lists it. */
export function runtimeEnvironment(
id: string,
runtimeId: string,
pairingRevision: number
): PublicKnownRuntimeEnvironment {
return {
id,
name: id,
createdAt: 1,
updatedAt: 1,
pairingRevision,
lastUsedAt: null,
runtimeId,
endpoints: [{ id: 'ws-1', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://server' }],
preferredEndpointId: 'ws-1'
}
}
type MirrorStore = {
getState: () => AppState
setState: (patch: Partial<AppState>) => void
}
/** Applies one session.tabs snapshot the way the mirror commits it. */
export function mirrorSnapshot(
store: MirrorStore,
environmentId: string,
snapshot: RuntimeMobileSessionTabsResult
): void {
store.setState(
applyWebSessionTabsSnapshotWithContext(store.getState(), snapshot, environmentId, NOW)
)
}
export type FakeLayoutStream = {
target: RuntimeClientTarget
onFrame: (frame: WorkspaceLayoutChangeFrame) => void
onStatus: (status: RuntimeLayoutStreamStatus) => void
onPosition: ((seq: number) => void) | undefined
close: () => void
closed: boolean
}
/** Every layout subscription the code under test opened, in order. */
export function createFakeLayoutStreams() {
const all: FakeLayoutStream[] = []
const subscribe = (
target: RuntimeClientTarget,
_params: unknown,
onFrame: FakeLayoutStream['onFrame'],
onStatus: FakeLayoutStream['onStatus'],
onPosition?: (seq: number) => void
): { close: () => void } => {
const stream: FakeLayoutStream = {
target,
onFrame,
onStatus,
onPosition,
closed: false,
close: () => {
stream.closed = true
}
}
all.push(stream)
return { close: () => stream.close() }
}
const of = (environmentId: string): FakeLayoutStream[] =>
all.filter(
(stream) =>
stream.target.kind === 'environment' && stream.target.environmentId === environmentId
)
/** The one open stream to a server; throws unless exactly one is open. */
const openTo = (environmentId: string): FakeLayoutStream => {
const open = of(environmentId).filter((stream) => !stream.closed)
if (open.length !== 1) {
throw new Error(`expected one open stream to ${environmentId}, found ${open.length}`)
}
return open[0]!
}
/** Delivers a frame as the client does: the frame, then its position. */
const send = (stream: FakeLayoutStream, frame: WorkspaceLayoutChangeFrame): void => {
stream.onFrame(frame)
const seq = 'seq' in frame ? frame.seq : undefined
if (seq !== undefined) {
stream.onPosition?.(seq)
}
}
return { all, subscribe, of, openTo, send }
}
export const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
/** What a reattach or an emptied workspace would change: tab ids, groups and pane bindings. */
export function treeOf(state: AppState, key: string) {
const tabIds = (state.tabsByWorktree[key] ?? []).map((tab) => tab.id)
return {
tabIds,
unifiedIds: (state.unifiedTabsByWorktree[key] ?? []).map((tab) => tab.id),
groups: (state.groupsByWorktree[key] ?? []).map((group) => [group.id, group.tabOrder]),
bindings: Object.fromEntries(
tabIds.map((id) => [id, state.terminalLayoutsByTabId[id]?.ptyIdsByLeafId])
)
}
}
export type Server = {
environmentId: string
runtimeId: string
pairingRevision?: number
capable: boolean
/** Paired still, but the host's own verdict is that the pairing is over. */
disconnected?: boolean
}
export const CAPABLE: Server = { environmentId: ENV, runtimeId: 'runtime-1', capable: true }
/** ENV's one workspace as the server publishes it, bound to its own `server-pty-1`. */
export const SERVER_LAYOUT = serverLayout([
serverTerminalTab('host-tab-1', LEAF_ID, 'server-pty-1')
])
export type TestStore = ReturnType<typeof createTestStore>
/** The reachable servers and workspace owners a test's mocks read, and its installed managers. */
export function createServerHarness(mocks: { owners: Map<string, string> }) {
const installs: (() => void)[] = []
return {
/** The paired servers, and what each one's verified status advertises. */
setServers: (store: TestStore, servers: Server[]): void => {
store.setState({
runtimeEnvironments: servers.map((server) =>
runtimeEnvironment(server.environmentId, server.runtimeId, server.pairingRevision ?? 1)
),
runtimeStatusByEnvironmentId: new Map(
servers.map((server) => [
server.environmentId,
runtimeStatusEntry(server.runtimeId, server.capable, server.disconnected)
])
)
})
},
/** A store whose WT mirrors ENV's one terminal, bound to `terminal-1`. */
mirroredStore: (): TestStore => {
const store = createTestStore()
mocks.owners.set(WT, ENV)
mirrorSnapshot(store, ENV, sessionTabs([surface('host-tab-1', LEAF_ID, 'terminal-1')], 1))
return store
},
install: (store: TestStore): void => {
installs.push(installServerLayoutFrames(store))
},
uninstallAll: (): void => {
for (const uninstall of installs.splice(0)) {
uninstall()
}
}
}
}
/** A sender's opening snapshot with the stream epoch its records frames carry. */
export function senderSnapshot(
sender: {
snapshot: (layouts: Record<string, PublishedWorkspaceLayout>, seq?: number) => unknown
},
layouts: Record<string, PublishedWorkspaceLayout>,
seq: number
): WorkspaceLayoutChangeFrame {
sender.snapshot(layouts, seq)
return {
type: 'snapshot',
workspaces: Object.entries(layouts).map(([key, layout]) => ({ key, layout })),
seq,
epoch: 'main-epoch'
}
}
@@ -0,0 +1,385 @@
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import type * as AgentStatusModule from '@/lib/agent-status'
import type * as FrameDrawnModule from './web-session-tabs-sync/frame-drawn-server-workspaces'
import type { AppState } from '@/store/types'
import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness'
import {
_resetLayoutFramePositionForTests,
afterLayoutFrame,
beginLayoutFramePositions,
noteLayoutFramePosition
} from './layout-frame-position'
import { mainLayoutFrameSender } from './main-loaded-layout-frames.test-fixture'
import { installLayoutFrameApplyCheck } from './workspace-layout-frame-apply-check.test-fixture'
import {
CAPABLE,
ENV,
LEAF_ID,
WT,
SERVER_LAYOUT,
createFakeLayoutStreams,
createServerHarness,
mirrorSnapshot,
serverLayout,
serverTerminalTab,
senderSnapshot,
sessionTabs,
settle,
surface,
treeOf
} from './server-layout-frames.test-fixture'
import {
isFrameDrawnServerWorkspace,
resetFrameDrawnServerWorkspacesForTests
} from './web-session-tabs-sync/frame-drawn-server-workspaces'
import { resetMirroredTerminalHandlesForTests } from './web-session-tabs-sync/mirrored-terminal-handle-index'
import { resetWebSessionTabsSyncTestState } from './web-session-tabs-sync-test-harness'
type Subscribe = (...args: never[]) => { close: () => void }
const hoisted = vi.hoisted(
(): {
owners: Map<string, string>
resync: Mock<(environmentId: string, worktreeId: string) => Promise<void>>
subscribe: Subscribe | null
} => ({
owners: new Map(),
resync: vi.fn(),
subscribe: null
})
)
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
vi.mock('@/lib/agent-status', async (importOriginal) => {
const actual = await importOriginal<typeof AgentStatusModule>()
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
})
vi.mock('./runtime-layout-client', () => ({
subscribeRuntimeLayout: (...args: never[]) => hoisted.subscribe!(...args)
}))
vi.mock('./web-session-tabs-sync/mirrored-workspace-owner', () => ({
mirroredRuntimeEnvironmentIdForWorkspace: (_state: unknown, key: string) =>
hoisted.owners.get(key) ?? null
}))
// Why not mock its RPC instead: vitest gives concurrent dynamic imports of a mocked module the real one.
vi.mock('./web-session-tabs-sync/frame-drawn-server-workspaces', async (importOriginal) => ({
...(await importOriginal<typeof FrameDrawnModule>()),
resyncMirroredServerWorkspace: hoisted.resync
}))
createStoreSessionMockApi()
const streams = createFakeLayoutStreams()
hoisted.subscribe = streams.subscribe
const TAB_1 = toWebTerminalSurfaceTabId('host-tab-1')
const OTHER_ENV = 'web-env-2'
const OTHER_WT = 'repo::/other-worktree'
const { setServers, mirroredStore, install, uninstallAll } = createServerHarness(hoisted)
beforeEach(() => {
hoisted.owners.clear()
hoisted.resync.mockReset().mockResolvedValue(undefined)
streams.all.length = 0
resetWebSessionTabsSyncTestState()
resetFrameDrawnServerWorkspacesForTests()
resetMirroredTerminalHandlesForTests()
_resetLayoutFramePositionForTests()
})
afterEach(uninstallAll)
describe('which servers get a layout stream', () => {
it('opens one only for a reachable server whose verified status advertises layout intents', () => {
const store = mirroredStore()
install(store)
expect(streams.all).toEqual([])
setServers(store, [
CAPABLE,
{ environmentId: OTHER_ENV, runtimeId: 'runtime-2', capable: false }
])
expect(streams.all.map((stream) => stream.target)).toEqual([
{ kind: 'environment', environmentId: ENV }
])
})
it('leaves every workspace of an older server to the mirror', async () => {
const store = mirroredStore()
install(store)
setServers(store, [{ ...CAPABLE, capable: false }])
await settle()
// Presence: the server is paired and connected, and its workspace mirrored.
expect(store.getState().runtimeEnvironments.map((environment) => environment.id)).toEqual([ENV])
expect(store.getState().runtimeStatusByEnvironmentId.get(ENV)?.status?.runtimeId).toBe(
'runtime-1'
)
expect(store.getState().tabsByWorktree[WT]?.map((tab) => tab.id)).toEqual([TAB_1])
expect(streams.all).toEqual([])
expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(false)
// The mirror still writes the layout: a renamed tab shows.
mirrorSnapshot(
store,
ENV,
sessionTabs([{ ...surface('host-tab-1', LEAF_ID, 'terminal-1'), title: 'renamed' }], 2)
)
expect(store.getState().tabsByWorktree[WT]?.[0]?.title).toBe('renamed')
expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(false)
})
it.each([
['is unpaired', []],
['disconnects', [{ ...CAPABLE, disconnected: true }]]
] as const)(
'closes the stream of a server that %s and hands its workspaces back (L19)',
async (_, after) => {
const store = mirroredStore()
install(store)
setServers(store, [CAPABLE])
const stream = streams.openTo(ENV)
streams.send(stream, {
type: 'snapshot',
workspaces: [{ key: WT, layout: SERVER_LAYOUT }],
seq: 1,
epoch: 'e'
})
await settle()
expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(true)
expect(hoisted.resync).not.toHaveBeenCalled()
setServers(store, [...after])
expect(stream.closed).toBe(true)
expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(false)
await settle()
expect(hoisted.resync.mock.calls).toEqual([[ENV, WT]])
}
)
it.each([
['a new runtime id', { runtimeId: 'runtime-1b' }],
['a new pairing revision', { pairingRevision: 2 }]
] as const)('replaces the stream on %s without handing back first', async (_, change) => {
const store = mirroredStore()
install(store)
setServers(store, [CAPABLE])
const first = streams.openTo(ENV)
streams.send(first, {
type: 'snapshot',
workspaces: [{ key: WT, layout: SERVER_LAYOUT }],
seq: 1,
epoch: 'e'
})
await settle()
expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(true)
setServers(store, [{ ...CAPABLE, ...change }])
await settle()
expect(first.closed).toBe(true)
const second = streams.openTo(ENV)
expect(second).not.toBe(first)
expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(true)
expect(hoisted.resync).not.toHaveBeenCalled()
expect(store.getState().tabsByWorktree[WT]?.map((tab) => tab.id)).toEqual([TAB_1])
})
it.each(['lost', 'refused'] as const)(
'hands the workspaces back when the replacement is %s before its snapshot',
async (state) => {
const store = mirroredStore()
install(store)
setServers(store, [CAPABLE])
streams.send(streams.openTo(ENV), {
type: 'snapshot',
workspaces: [{ key: WT, layout: SERVER_LAYOUT }],
seq: 1,
epoch: 'e'
})
await settle()
setServers(store, [{ ...CAPABLE, runtimeId: 'runtime-1b' }])
expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(true)
streams
.openTo(ENV)
.onStatus(state === 'lost' ? { state } : { state, code: 'method_not_found' })
await settle()
expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(false)
expect(hoisted.resync.mock.calls).toEqual([[ENV, WT]])
}
)
})
describe('frames to mirror', () => {
it.each(['lost', 'refused'] as const)(
'clears the gate and resyncs each workspace when the stream is %s, keeping the frame tree until then',
async (state) => {
const store = mirroredStore()
hoisted.owners.set(OTHER_WT, ENV)
mirrorSnapshot(store, ENV, {
...sessionTabs([surface('host-tab-9', LEAF_ID, 'terminal-9')], 1),
worktree: OTHER_WT
})
install(store)
setServers(store, [CAPABLE])
const stream = streams.openTo(ENV)
streams.send(stream, {
type: 'snapshot',
workspaces: [
{ key: WT, layout: SERVER_LAYOUT },
{
key: OTHER_WT,
layout: {
...serverLayout([serverTerminalTab('host-tab-9', LEAF_ID, 'server-pty-9')]),
worktreeId: OTHER_WT
}
}
],
seq: 1,
epoch: 'e'
})
await settle()
expect([
isFrameDrawnServerWorkspace(ENV, WT),
isFrameDrawnServerWorkspace(ENV, OTHER_WT)
]).toEqual([true, true])
const frameTree = treeOf(store.getState(), WT)
expect(frameTree.tabIds).toEqual([TAB_1])
// The resync never answers here: whatever the store shows meanwhile is the frames'.
hoisted.resync.mockReturnValue(new Promise(() => {}))
const states: AppState[] = []
const stop = store.subscribe((next) => states.push(next))
stream.onStatus(state === 'lost' ? { state } : { state, code: 'forbidden' })
await settle()
stop()
expect([
isFrameDrawnServerWorkspace(ENV, WT),
isFrameDrawnServerWorkspace(ENV, OTHER_WT)
]).toEqual([false, false])
expect(hoisted.resync.mock.calls).toEqual([
[ENV, WT],
[ENV, OTHER_WT]
])
expect(treeOf(store.getState(), WT)).toEqual(frameTree)
for (const recorded of states) {
expect(treeOf(recorded, WT)).toEqual(frameTree)
}
}
)
})
describe('a workspace that turns out to be the server’s after its snapshot', () => {
it('stays the mirror’s until routing names its owner, then draws from frames until the stream stops', async () => {
const store = mirroredStore()
hoisted.owners.delete(WT)
install(store)
setServers(store, [CAPABLE])
const check = installLayoutFrameApplyCheck()
try {
const stream = streams.openTo(ENV)
const frames = mainLayoutFrameSender()
const before = store.getState()
const recorded: AppState[] = []
const stop = store.subscribe((state) => recorded.push(state))
streams.send(stream, senderSnapshot(frames, { [WT]: SERVER_LAYOUT }, 1))
await settle()
stop()
// Presence: the snapshot carried the workspace, and the mirror drew it.
expect(treeOf(before, WT).tabIds).toEqual([TAB_1])
expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(false)
expect(recorded).toEqual([])
expect(check.fullBuilt).toEqual([])
hoisted.owners.set(WT, ENV)
// A routing slice moves, as pairing does when it names the workspace's owner.
store.setState({ repos: [...store.getState().repos] })
expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(true)
await settle()
expect(check.fullBuilt).toEqual([WT])
const renamed = structuredClone(SERVER_LAYOUT)
renamed.tabs[0]!.customTitle = 'renamed on the server'
streams.send(stream, frames.records(WT, renamed))
await settle()
expect(store.getState().tabsByWorktree[WT]?.[0]?.customTitle).toBe('renamed on the server')
stream.onStatus({ state: 'lost' })
await settle()
expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(false)
expect(hoisted.resync.mock.calls).toEqual([[ENV, WT]])
// The owner still reads ENV: a routing change after the loss must not take it back.
expect(hoisted.owners.get(WT)).toBe(ENV)
store.setState({ repos: [...store.getState().repos] })
await settle()
expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(false)
expect(check.failures).toEqual([])
} finally {
check.uninstall()
}
})
})
describe('a workspace whose owner briefly reads unknown', () => {
it('is drawn from frames in full again when it rejoins the gate', async () => {
const store = mirroredStore()
install(store)
setServers(store, [CAPABLE])
const check = installLayoutFrameApplyCheck()
try {
const stream = streams.openTo(ENV)
streams.send(stream, senderSnapshot(mainLayoutFrameSender(), { [WT]: SERVER_LAYOUT }, 1))
await settle()
expect(check.fullBuilt).toEqual([WT])
hoisted.owners.delete(WT)
store.setState({ repos: [...store.getState().repos] })
expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(false)
hoisted.owners.set(WT, ENV)
store.setState({ repos: [...store.getState().repos] })
expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(true)
await settle()
// The mirror may have drawn it in the gap: the frames redraw it rather than trust it.
expect(check.fullBuilt).toEqual([WT, WT])
expect(check.failures).toEqual([])
} finally {
check.uninstall()
}
})
})
describe('server frames and this window’s layout.apply positions', () => {
it('never release a local wait, whatever their seq, nor end it when the server stream stops', async () => {
const store = mirroredStore()
install(store)
setServers(store, [CAPABLE])
beginLayoutFramePositions()
const waiter = vi.fn()
afterLayoutFrame(3, waiter)
const stream = streams.openTo(ENV)
// Presence: the stream reports positions, as the real client does.
expect(stream.onPosition).toBeTypeOf('function')
const frames = mainLayoutFrameSender()
streams.send(stream, senderSnapshot(frames, { [WT]: SERVER_LAYOUT }, 5))
const renamed = structuredClone(SERVER_LAYOUT)
renamed.tabs[0]!.customTitle = 'renamed'
streams.send(stream, frames.records(WT, renamed, 9))
await settle()
// Presence: the frames did show.
expect(store.getState().tabsByWorktree[WT]?.[0]?.customTitle).toBe('renamed')
stream.onStatus({ state: 'lost' })
await settle()
expect(waiter).not.toHaveBeenCalled()
noteLayoutFramePosition(3)
expect(waiter).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,231 @@
// A paired server whose main takes layout intents (`layout.intents.v1`) draws its workspaces in
// this window from its layout stream instead of the session.tabs mirror (design H5). One source per
// workspace at a time: the mirror stops writing a workspace's layout as the stream's snapshot comes
// in, and gets it back, with a fresh snapshot, when the stream stops. session.tabs keeps flowing for
// terminal handles and statuses either way.
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status'
import { LAYOUT_INTENTS_RUNTIME_CAPABILITY } from '../../../shared/workspace-layout/layout-intents-capability'
import {
isDisconnectedRuntimeHostState,
runtimeHostConnectionStateForEntry
} from './runtime-host-connection-state'
import { createServerLayoutFrameTranslator } from './server-layout-frame-ids'
import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync'
import { mirroredRuntimeEnvironmentIdForWorkspace } from './web-session-tabs-sync/mirrored-workspace-owner'
import {
isFrameDrawnServerWorkspace,
mirroredTerminalHandle,
onMirroredTerminalHandlesChanged,
resyncMirroredServerWorkspace,
setFrameDrawnServerWorkspaces
} from './web-session-tabs-sync/frame-drawn-server-workspaces'
type FramesStore = {
getState: () => AppState
setState: (patch: Partial<AppState>) => void
subscribe: (listener: (state: AppState) => void) => () => void
}
type ServerFrames = {
/** Hands the server's workspaces back to the mirror unless a replacement stream takes them. */
close: (handBack: boolean) => void
/** The workspaces the mirror leaves to this stream now. */
gated: () => ReadonlySet<string>
}
/** Workspaces this server's stream draws: ones the window holds that it mirrors from this server. */
function serverDraws(environmentId: string): (state: AppState) => (key: string) => boolean {
return (state) => (key) =>
(key in state.groupsByWorktree || key in state.unifiedTabsByWorktree) &&
mirroredRuntimeEnvironmentIdForWorkspace(state, key) === environmentId
}
function openServerFrames(
store: FramesStore,
environmentId: string,
/** A replaced stream's workspaces: this one hands them back if it never takes them over. */
inherited: ReadonlySet<string>
): ServerFrames {
const translator = createServerLayoutFrameTranslator(
environmentId,
(worktreeId) => (hostTabId, leafId) =>
mirroredTerminalHandle(environmentId, worktreeId, hostTabId, leafId)
)
const owns = (key: string): boolean =>
mirroredRuntimeEnvironmentIdForWorkspace(store.getState(), key) === environmentId
let gated = inherited
/** From the stream's snapshot until it stops: only then do its frames draw. */
let live = false
/** Gates `keys`; a workspace leaving the gate that is still this server's returns to the mirror. */
const gate = (keys: ReadonlySet<string>): void => {
const returned = [...gated].filter((key) => !keys.has(key) && owns(key))
gated = keys
setFrameDrawnServerWorkspaces(environmentId, keys)
for (const key of returned) {
// The frame tree stays until the fresh snapshot replaces it: never an empty tree between.
resyncMirroredServerWorkspace(environmentId, key).catch((error: unknown) => {
console.warn('[layout-owner] a server workspace could not return to the mirror:', error)
})
}
}
/** Gates the workspaces the stream holds that are this server's; returns the ones newly gated. */
const regate = (): string[] => {
const keys = new Set(translator.keys().filter(owns))
const added = [...keys].filter((key) => !gated.has(key))
if (added.length > 0 || keys.size !== gated.size) {
gate(keys)
}
return added
}
const handBack = (): void => {
live = false
gate(new Set())
}
const draws = serverDraws(environmentId)
const sync = openWorkspaceLayoutFrameSync(store, {
target: { kind: 'environment', environmentId },
// Only gated workspaces: a handed-back one is the mirror's, whatever the frozen frames still hold.
draws: (state) => {
const ours = draws(state)
return (key) => gated.has(key) && ours(key)
},
translate: (frame) => {
const translated = translator.translate(frame)
live ||= frame.type === 'snapshot'
// Before the frame shows: the mirror must not write a workspace the frames now draw.
regate()
return translated
},
onStatus: (status) => {
if (status.state === 'lost' || status.state === 'refused') {
handBack()
}
}
})
// A workspace can turn out to be this server's after its snapshot (pairing just finished): it is
// gated, then drawn, as the routing that names it arrives.
let routing: unknown[] = []
const stopRouting = store.subscribe((state) => {
const next = routingInputs(state)
if (sameValues(next, routing)) {
return
}
routing = next
const added = live ? regate() : []
if (added.length > 0) {
// The mirror may have drawn them meanwhile.
sync.redraw(added)
}
})
const stopHandles = onMirroredTerminalHandlesChanged((environment, worktreeId) => {
const frame = environment === environmentId ? translator.rebind(worktreeId) : null
if (frame) {
sync.receive(frame)
}
})
sync.start()
return {
gated: () => gated,
close: (returnWorkspaces) => {
stopRouting()
stopHandles()
sync.close()
if (returnWorkspaces) {
handBack()
}
}
}
}
/** Opens one stream per reachable server advertising the capability; reopens on a new runtime or
* pairing, and closes when the server is removed or stops advertising it. */
export function installServerLayoutFrames(store: FramesStore): () => void {
const open = new Map<string, { identity: string; frames: ServerFrames }>()
let inputs: unknown[] = []
const reconcile = (state: AppState): void => {
const next = [state.runtimeEnvironments, state.runtimeStatusByEnvironmentId]
if (sameValues(next, inputs)) {
return
}
inputs = next
const wanted = new Map<string, string>()
for (const environment of state.runtimeEnvironments) {
const entry = state.runtimeStatusByEnvironmentId.get(environment.id)
const status = lastVerifiedRuntimeStatus(entry)
// Why every paired server, not the focused one: each draws only the workspaces it owns.
if (
status?.capabilities?.includes(LAYOUT_INTENTS_RUNTIME_CAPABILITY) &&
!isDisconnectedRuntimeHostState(runtimeHostConnectionStateForEntry(entry))
) {
wanted.set(
environment.id,
`${status.runtimeId}|${environment.pairingRevision ?? environment.createdAt}|${entry?.connectionGeneration ?? 0}`
)
}
}
const replaced = new Map<string, ReadonlySet<string>>()
for (const [environmentId, entry] of open) {
if (wanted.get(environmentId) !== entry.identity) {
open.delete(environmentId)
// A replacement stream's snapshot takes the workspaces over; anything else returns them.
const replacing = wanted.has(environmentId)
entry.frames.close(!replacing)
if (replacing) {
replaced.set(environmentId, entry.frames.gated())
}
}
}
for (const [environmentId, identity] of wanted) {
if (!open.has(environmentId)) {
const frames = openServerFrames(
store,
environmentId,
replaced.get(environmentId) ?? new Set()
)
open.set(environmentId, { identity, frames })
}
}
}
reconcile(store.getState())
const stop = store.subscribe(reconcile)
return () => {
stop()
for (const { frames } of open.values()) {
frames.close(true)
}
open.clear()
}
}
/** The store slices a workspace's owning server derives from. */
function routingInputs(state: AppState): unknown[] {
return [
state.repos,
state.worktreesByRepo,
state.detectedWorktreesByRepo,
state.projectGroups,
state.restoredRuntimeHostIdByWorkspaceSessionKey,
state.runtimeEnvironments,
state.runtimeStatusByEnvironmentId
]
}
/** Compared by reference: store slices are replaced, never edited in place. */
function sameValues(a: readonly unknown[], b: readonly unknown[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index])
}
let installed: (() => void) | null = null
/** After restore: each capable paired server's workspaces draw from its stream from now on. */
export function showServerLayoutFrames(): void {
// Kept for the document's life: a re-run boot reuses the streams already open.
installed ??= installServerLayoutFrames(useAppStore)
if (import.meta.env.MODE === 'e2e') {
window.__isFrameDrawnServerWorkspaceE2E = isFrameDrawnServerWorkspace
}
}
@@ -19,7 +19,7 @@ import {
recordReceivedWebSessionTabsSnapshot,
shouldApplyRecoveredWebSessionTabsSnapshot
} from './web-session-tabs-sync/tracking'
import { recoverWebSessionTerminalOrphansBeforeApply } from './web-session-terminal-orphan-recovery'
import { recoverMirroredTerminalOrphansBeforeApply } from './web-session-tabs-sync/mirror-terminal-orphan-recovery'
const pendingRuntimeWorktreeRecoveryRefreshes = new Map<string, symbol>()
const RUNTIME_WORKTREE_RECOVERY_REFRESH_DELAYS_MS = [250, 500, 1_000, 2_000, 4_000] as const
@@ -116,7 +116,7 @@ export async function refreshWebRuntimeSessionTabsSnapshot(
if (getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision) {
return
}
const recovered = await recoverWebSessionTerminalOrphansBeforeApply(
const recovered = await recoverMirroredTerminalOrphansBeforeApply(
useAppStore.getState(),
snapshot,
environmentId,
@@ -2,9 +2,11 @@ import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import { isRuntimeSubscriptionReplayResponse } from '../../../../shared/runtime-subscription-replay'
import { useAppStore } from '../../store'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { isFrameDrawnServerWorkspace } from './frame-drawn-server-workspaces'
import { mirroredRuntimeEnvironmentIdForWorkspace } from './mirrored-workspace-owner'
import { getRuntimeEnvironmentRevision } from '../runtime-environment-revision'
import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery'
import { recoverMirroredTerminalOrphansBeforeApply } from './mirror-terminal-orphan-recovery'
import {
installWindowVisibilitySubscriptionParking,
type WindowVisibilitySubscriptionParking
@@ -108,7 +110,7 @@ export function installActiveSessionTabsSubscription({
receivedFrame: number,
runtimeId?: string
): Promise<HostSessionMirrorSettle | null> => {
const recovered = await recoverWebSessionTerminalOrphansBeforeApply(
const recovered = await recoverMirroredTerminalOrphansBeforeApply(
useAppStore.getState(),
event,
environmentId,
@@ -150,7 +152,10 @@ export function installActiveSessionTabsSubscription({
snapshotIsFresh: decision.apply,
localTerminalCount,
hasLiveLocalPty,
skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId)
skipWakeRespawn:
shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId) ||
// Switch point: ORCA_LAYOUT_OWNER_IS_MAIN && — a frame-drawn workspace's terminals are not the mirror's to respawn.
(ORCA_LAYOUT_OWNER_IS_MAIN && isFrameDrawnServerWorkspace(environmentId, activeWorktreeId))
})
let settle: HostSessionMirrorSettle | null = decision.apply
? null
@@ -1,4 +1,5 @@
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import type {
WebSessionTabsBatchContext,
@@ -15,6 +16,9 @@ import { applyBrowserRecordUpdates } from './apply-browser-records'
import { applyWorktreeRecordUpdates } from './apply-worktree-records'
import { applyActiveStateUpdates } from './apply-active-state'
import { buildWebSessionTabsFinalPatch } from './apply-final-patch'
import { isFrameDrawnServerWorkspace } from './frame-drawn-server-workspaces'
import { buildFrameDrawnWorkspaceStatusPatch } from './frame-drawn-workspace-status-patch'
import { indexMirroredTerminalSurfaces } from './mirrored-terminal-handle-index'
/** Reconcile one host frame through the staged terminal/browser/layout pipeline. */
export function applyWebSessionTabsSnapshotWithContext(
@@ -32,17 +36,39 @@ export function applyWebSessionTabsSnapshotWithContext(
return state
}
const worktreeId = rawSnapshot.worktree
// Switch point: ORCA_LAYOUT_OWNER_IS_MAIN && — every mirror write (single, batch, operations) passes here.
const previousSnapshotTabIds =
ORCA_LAYOUT_OWNER_IS_MAIN && options?.contentScope !== 'agent-session'
? indexMirroredTerminalSurfaces(environmentId, rawSnapshot)
: null
const frameDrawnPreviousTabIds =
previousSnapshotTabIds && isFrameDrawnServerWorkspace(environmentId, worktreeId)
? previousSnapshotTabIds
: null
// A frame-drawn workspace still runs these stages for their intent and id-mapping ledgers; unbatched
// so their discarded record copies never reach the batch's shared state.
const base = prepareWebSessionTabsSnapshotBase(
state,
rawSnapshot,
environmentId,
worktreeId,
now,
batchContext,
frameDrawnPreviousTabIds ? undefined : batchContext,
options
)
const browser = prepareWebSessionTabsSnapshotBrowser(base)
const unified = prepareWebSessionTabsSnapshotUnified(browser)
if (frameDrawnPreviousTabIds) {
return buildFrameDrawnWorkspaceStatusPatch(
state,
base.snapshot,
environmentId,
worktreeId,
frameDrawnPreviousTabIds,
now,
batchContext
)
}
const groups = prepareWebSessionTabsSnapshotGroups(unified)
const terminalRecords = applyTerminalRecordUpdates(groups)
const browserRecords = applyBrowserRecordUpdates(terminalRecords)
@@ -0,0 +1,292 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { toWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id'
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import {
ENV,
LEAF_ID,
NOW,
SECOND_LEAF_ID,
WT,
makeSnapshot,
makeState,
resetWebSessionTabsSyncTestState
} from '../web-session-tabs-sync-test-harness'
import { applyWebSessionTabsSnapshot, applyWebSessionTabsSnapshots } from './snapshot-api'
import { decideWebSessionTabsSnapshot } from './tracking-decisions'
import type { WebSessionTabsSyncState } from './state'
import {
clearHostSessionTabIdMappings,
resolveHostSessionTabIdForWebSessionTab
} from './tracking-mappings'
import {
isWebSessionCloseIntentPending,
recordWebSessionCloseIntent
} from '../web-session-close-intent'
import { peekWebSessionFocusIntent, recordWebSessionFocusIntent } from '../web-session-focus-intent'
import {
isFrameDrawnServerWorkspace,
mirroredTerminalHandle,
onMirroredTerminalHandlesChanged,
resetFrameDrawnServerWorkspacesForTests,
resyncMirroredServerWorkspace,
setFrameDrawnServerWorkspaces
} from './frame-drawn-server-workspaces'
const hoisted = vi.hoisted(() => ({
layoutOwnerIsMain: true,
refresh: vi.fn<(environmentId: string, worktreeId: string) => Promise<void>>()
}))
vi.mock('../../../../shared/layout-owner-switch', () => ({
get ORCA_LAYOUT_OWNER_IS_MAIN() {
return hoisted.layoutOwnerIsMain
}
}))
vi.mock('../../store', () => ({ useAppStore: { setState: vi.fn() } }))
vi.mock('../web-runtime-session-snapshot', () => ({
refreshWebRuntimeSessionTabsSnapshot: hoisted.refresh
}))
const OTHER_WT = 'repo::/other-worktree'
const MIRRORED_TAB_ID = toWebTerminalSurfaceTabId('host-tab-1')
const MIRRORED_PANE_KEY = makePaneKey(MIRRORED_TAB_ID, LEAF_ID)
const LAYOUT_SLICES = [
'tabsByWorktree',
'unifiedTabsByWorktree',
'groupsByWorktree',
'layoutByWorktree',
'tabBarOrderByWorktree',
'terminalLayoutsByTabId',
'ptyIdsByTabId',
'openFiles',
'editorBuffers',
'browserTabsByWorktree',
'browserPagesByWorkspace',
'remoteBrowserPageHandlesByPageId',
'activeTabIdByWorktree',
'activeGroupIdByWorktree',
'activeFileIdByWorktree',
'activeTabTypeByWorktree',
'activeTabId',
'activeFileId',
'activeTabType',
'pendingStartupByTabId',
'automaticAgentResumeClaimsByTabId'
] as const
type Surface = { hostTabId: string; leafId: string; title: string; terminal: string | null }
function terminalSurface({ hostTabId, leafId, title, terminal }: Surface, working = false) {
const base = {
type: 'terminal' as const,
id: `${hostTabId}::${leafId}`,
title,
parentTabId: hostTabId,
leafId,
isActive: true,
...(working
? {
agentStatus: {
state: 'working' as const,
prompt: 'keep going',
updatedAt: NOW - 100,
stateStartedAt: NOW - 1_000,
agentType: 'codex' as const,
paneKey: makePaneKey(hostTabId, leafId),
tabId: hostTabId,
worktreeId: WT,
terminalTitle: title,
stateHistory: []
}
}
: {})
}
return terminal === null
? { ...base, status: 'pending-handle' as const, terminal: null }
: { ...base, status: 'ready' as const, terminal }
}
function shell(title: string, version: number, working = false, worktree = WT) {
return makeSnapshot(
[
terminalSurface(
{ hostTabId: 'host-tab-1', leafId: LEAF_ID, title, terminal: 'terminal-1' },
working
)
],
{ worktree, snapshotVersion: version }
)
}
function merge(
state: WebSessionTabsSyncState,
patch: WebSessionTabsSyncState | Partial<WebSessionTabsSyncState>
): WebSessionTabsSyncState {
return { ...state, ...patch }
}
/** A store whose WT tree is the frames' own drawing (same ids, its own title). */
function frameDrawnStore(): WebSessionTabsSyncState {
const mirrored = merge(
makeState(),
applyWebSessionTabsSnapshot(makeState(), shell('a', 1), ENV, NOW)
)
const frameTab = { ...mirrored.tabsByWorktree[WT]![0]!, title: 'drawn by frames' }
setFrameDrawnServerWorkspaces(ENV, new Set([WT]))
return { ...mirrored, tabsByWorktree: { ...mirrored.tabsByWorktree, [WT]: [frameTab] } }
}
const flushMicrotasks = () => new Promise<void>((resolve) => queueMicrotask(resolve))
describe('frame-drawn server workspaces', () => {
beforeEach(() => {
hoisted.layoutOwnerIsMain = true
hoisted.refresh.mockReset()
resetWebSessionTabsSyncTestState()
resetFrameDrawnServerWorkspacesForTests()
})
it('applies agent status but writes no layout slice for a frame-drawn workspace', () => {
const store = frameDrawnStore()
const patch = applyWebSessionTabsSnapshot(store, shell('b', 2, true), ENV, NOW)
expect(patch).not.toBe(store)
for (const slice of LAYOUT_SLICES) {
expect(Object.hasOwn(patch, slice), slice).toBe(false)
}
expect(patch.agentStatusByPaneKey?.[MIRRORED_PANE_KEY]).toMatchObject({
state: 'working',
tabId: MIRRORED_TAB_ID,
worktreeId: WT
})
})
it('still mirrors every slice for another workspace of the same environment, batched', () => {
const store = frameDrawnStore()
const frameTree = store.tabsByWorktree[WT]
const patch = applyWebSessionTabsSnapshots(
store,
[shell('b', 2, true), shell('other', 1, false, OTHER_WT)],
ENV,
NOW
)
expect(patch.tabsByWorktree?.[WT]).toBe(frameTree)
expect(patch.tabsByWorktree?.[OTHER_WT]?.[0]?.title).toBe('other')
expect(patch.agentStatusByPaneKey?.[MIRRORED_PANE_KEY]?.state).toBe('working')
})
it('drops unread markers for a tab the host retracted', () => {
const store = { ...frameDrawnStore(), unreadTerminalTabs: { [MIRRORED_TAB_ID]: true as const } }
const patch = applyWebSessionTabsSnapshot(
store,
makeSnapshot([], { snapshotVersion: 2 }),
ENV,
NOW
)
expect(patch.unreadTerminalTabs).toEqual({})
expect(Object.hasOwn(patch, 'tabsByWorktree')).toBe(false)
})
it('indexes ready handles from every snapshot and notifies once per changed snapshot', async () => {
const listener = vi.fn()
onMirroredTerminalHandlesChanged(listener)
const surfaces = (second: string | null, firstTerminal = 'terminal-1') =>
makeSnapshot([
terminalSurface({
hostTabId: 'host-tab-1',
leafId: LEAF_ID,
title: 'a',
terminal: firstTerminal
}),
...(second === 'gone'
? []
: [
terminalSurface({
hostTabId: 'host-tab-1',
leafId: SECOND_LEAF_ID,
title: 'b',
terminal: second
})
])
])
const handle = (leafId: string) => mirroredTerminalHandle(ENV, WT, 'host-tab-1', leafId)
applyWebSessionTabsSnapshot(makeState(), surfaces('terminal-2'), ENV, NOW)
await flushMicrotasks()
expect([handle(LEAF_ID), handle(SECOND_LEAF_ID)]).toEqual(['terminal-1', 'terminal-2'])
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenLastCalledWith(ENV, WT)
applyWebSessionTabsSnapshot(makeState(), surfaces('terminal-2'), ENV, NOW)
await flushMicrotasks()
expect(listener).toHaveBeenCalledTimes(1)
setFrameDrawnServerWorkspaces(ENV, new Set([WT]))
applyWebSessionTabsSnapshot(makeState(), surfaces(null), ENV, NOW)
await flushMicrotasks()
expect(handle(SECOND_LEAF_ID)).toBeNull()
expect(listener).toHaveBeenCalledTimes(2)
applyWebSessionTabsSnapshot(makeState(), surfaces('gone', 'terminal-3'), ENV, NOW)
await flushMicrotasks()
expect([handle(LEAF_ID), handle(SECOND_LEAF_ID)]).toEqual(['terminal-3', null])
expect(listener).toHaveBeenCalledTimes(3)
})
it('hands a workspace back: the snapshot replaces the frame tree in one write', async () => {
let store = frameDrawnStore()
const commits: WebSessionTabsSyncState[] = []
const deliver = (snapshot: RuntimeMobileSessionTabsResult) => {
if (decideWebSessionTabsSnapshot(snapshot, ENV).apply) {
store = merge(store, applyWebSessionTabsSnapshot(store, snapshot, ENV, NOW))
commits.push(store)
}
}
// A frames-era snapshot advances freshness without touching the tree.
deliver(shell('host tree', 2))
expect(store.tabsByWorktree[WT]?.[0]?.title).toBe('drawn by frames')
hoisted.refresh.mockImplementation(async () => deliver(shell('host tree', 2)))
await resyncMirroredServerWorkspace(ENV, WT)
expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(false)
expect(hoisted.refresh).toHaveBeenCalledWith(ENV, WT, { errorMode: 'throw' })
expect(commits.map((commit) => commit.tabsByWorktree[WT]?.map((tab) => tab.title))).toEqual([
['drawn by frames'],
['host tree']
])
})
it('keeps the id-mapping and intent ledgers running for a frame-drawn workspace', () => {
const store = frameDrawnStore()
clearHostSessionTabIdMappings(ENV, WT)
const owner = { environmentId: ENV }
recordWebSessionCloseIntent(owner, WT, 'host-tab-gone', NOW)
recordWebSessionFocusIntent(owner, WT, 'host-tab-1', LEAF_ID)
// Nothing but layout changed, so the store is untouched.
expect(applyWebSessionTabsSnapshot(store, shell('b', 2), ENV, NOW)).toBe(store)
expect(
resolveHostSessionTabIdForWebSessionTab(store, {
environmentId: ENV,
worktreeId: WT,
tabId: MIRRORED_TAB_ID
})
).toBe('host-tab-1')
expect(isWebSessionCloseIntentPending(owner, WT, 'host-tab-gone', NOW)).toBe(false)
expect(peekWebSessionFocusIntent(owner, WT)).toBeNull()
})
it('changes nothing with the switch off', () => {
hoisted.layoutOwnerIsMain = false
setFrameDrawnServerWorkspaces(ENV, new Set([WT]))
const patch = applyWebSessionTabsSnapshot(makeState(), shell('a', 1), ENV, NOW)
expect(patch.tabsByWorktree?.[WT]?.[0]?.id).toBe(MIRRORED_TAB_ID)
expect(mirroredTerminalHandle(ENV, WT, 'host-tab-1', LEAF_ID)).toBeNull()
})
})
@@ -0,0 +1,51 @@
import { acceptReplayedWebSessionTabsSnapshot } from './state'
export {
mirroredTerminalHandle,
onMirroredTerminalHandlesChanged
} from './mirrored-terminal-handle-index'
// Why one source per workspace: frames and the mirror writing the same tree would fight.
const frameDrawnWorktreeIdsByEnvironment = new Map<string, ReadonlySet<string>>()
/**
* The workspaces of `environmentId` that a server's layout frames draw; replaces the previous set.
* A workspace leaving the set keeps its frame tree until `resyncMirroredServerWorkspace` replaces it.
*/
export function setFrameDrawnServerWorkspaces(
environmentId: string,
worktreeIds: ReadonlySet<string>
): void {
if (worktreeIds.size === 0) {
frameDrawnWorktreeIdsByEnvironment.delete(environmentId)
return
}
frameDrawnWorktreeIdsByEnvironment.set(environmentId, new Set(worktreeIds))
}
export function isFrameDrawnServerWorkspace(environmentId: string, worktreeId: string): boolean {
return frameDrawnWorktreeIdsByEnvironment.get(environmentId)?.has(worktreeId) ?? false
}
/** Hands a workspace back: a fresh full snapshot replaces what frames drew. Rejects when the fetch fails. */
export async function resyncMirroredServerWorkspace(
environmentId: string,
worktreeId: string
): Promise<void> {
const current = frameDrawnWorktreeIdsByEnvironment.get(environmentId)
if (current?.has(worktreeId)) {
setFrameDrawnServerWorkspaces(
environmentId,
new Set([...current].filter((id) => id !== worktreeId))
)
}
// Why lazy: keeps the RPC layer out of the apply's import graph, as the tab lifecycle module does.
const { refreshWebRuntimeSessionTabsSnapshot } = await import('../web-runtime-session-snapshot')
// Why replay: frames-era frames already advanced freshness, so the current version would read as stale.
acceptReplayedWebSessionTabsSnapshot(environmentId, worktreeId)
await refreshWebRuntimeSessionTabsSnapshot(environmentId, worktreeId, { errorMode: 'throw' })
}
export function resetFrameDrawnServerWorkspacesForTests(): void {
frameDrawnWorktreeIdsByEnvironment.clear()
}
@@ -0,0 +1,115 @@
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import { toWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id'
import { buildMirroredAgentStatusPatch } from './agent-status-patch'
import {
buildRemirroredClosedTabMarkerLiftPatch,
buildRetractedMirroredTabSweepPatch
} from './agent-status-primitives'
import { collectCollidingRetractionPaneKeys } from './retraction-pane-ownership'
import { isWebSessionTabsWorktreeRemovalFrame } from './session-tabs-inventory-absence'
import type { WebSessionTabsBatchContext, WebSessionTabsSyncState } from './state'
import { writableWebSessionTabsRecord } from './state-equality-core'
import { buildMirroredTerminalTabs } from './terminal-build'
import { isMirroredTerminalSurfaceId, isTerminalSurfaceTab } from './terminal-surfaces'
/**
* A frame-drawn workspace's share of one snapshot: agent status and unread markers only. Its tabs,
* layout, bindings and focus belong to the layout frames, so none of those slices are written.
*/
export function buildFrameDrawnWorkspaceStatusPatch(
state: WebSessionTabsSyncState,
snapshot: RuntimeMobileSessionTabsResult,
environmentId: string,
worktreeId: string,
previousSnapshotTabIds: ReadonlySet<string>,
now: number,
batchContext?: WebSessionTabsBatchContext
): WebSessionTabsSyncState | Partial<WebSessionTabsSyncState> {
const currentTerminalTabs = state.tabsByWorktree[worktreeId] ?? []
const terminalSurfaceTabs = snapshot.tabs.filter(isTerminalSurfaceTab)
const publishedTabIds = new Set(
terminalSurfaceTabs.map((surface) => toWebTerminalSurfaceTabId(surface.parentTabId))
)
const removalFrame = isWebSessionTabsWorktreeRemovalFrame(snapshot)
// Why both sources: frames may drop a retracted tab before or after the host's snapshot says so.
const retractedTabIds = [
...new Set([
...previousSnapshotTabIds,
...currentTerminalTabs.map((tab) => tab.id).filter(isMirroredTerminalSurfaceId)
])
].filter((tabId) => !publishedTabIds.has(tabId))
const retractionPaneKeys = collectCollidingRetractionPaneKeys(
state,
retractedTabIds,
environmentId,
worktreeId,
batchContext
)
const agentStatusPatch = buildMirroredAgentStatusPatch(
state,
currentTerminalTabs,
terminalSurfaceTabs,
// Only consulted for pane bindings retained through a pruned leaf; never written.
buildMirroredTerminalTabs(
snapshot,
environmentId,
new Map(currentTerminalTabs.map((tab) => [tab.id, tab])),
state.terminalLayoutsByTabId,
state.ptyIdsByTabId,
0,
now
),
environmentId,
worktreeId,
new Set(removalFrame ? [] : retractedTabIds),
now,
batchContext
)
const retiredTabs = new Set(retractedTabIds)
// The sweep requires the retired tabs to be absent; frames may still hold them, so hide them here only.
const sweepTabsByWorktree = {
...state.tabsByWorktree,
[worktreeId]: currentTerminalTabs.filter((tab) => !retiredTabs.has(tab.id))
}
const retractedTabSweepPatch = removalFrame
? null
: buildRetractedMirroredTabSweepPatch(
state,
sweepTabsByWorktree,
agentStatusPatch,
retractedTabIds,
retractionPaneKeys,
batchContext
)
const remirroredClosedTabLiftPatch = buildRemirroredClosedTabMarkerLiftPatch(
retractedTabSweepPatch?.recentlyClosedAgentStatusTabIds ??
state.recentlyClosedAgentStatusTabIds,
publishedTabIds
)
let nextUnreadTerminalTabs = state.unreadTerminalTabs
for (const tabId of retractedTabIds) {
if (!nextUnreadTerminalTabs[tabId]) {
continue
}
if (nextUnreadTerminalTabs === state.unreadTerminalTabs) {
nextUnreadTerminalTabs = writableWebSessionTabsRecord(
state,
'unreadTerminalTabs',
batchContext
)
}
delete nextUnreadTerminalTabs[tabId]
}
const patch: Partial<WebSessionTabsSyncState> = {
...agentStatusPatch,
...retractedTabSweepPatch,
...remirroredClosedTabLiftPatch,
...(nextUnreadTerminalTabs !== state.unreadTerminalTabs
? { unreadTerminalTabs: nextUnreadTerminalTabs }
: {})
}
return Object.keys(patch).length === 0 ? state : patch
}
@@ -2,7 +2,7 @@ import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import { isRuntimeSubscriptionReplayResponse } from '../../../../shared/runtime-subscription-replay'
import { useAppStore } from '../../store'
import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery'
import { recoverMirroredTerminalOrphansBeforeApply } from './mirror-terminal-orphan-recovery'
import {
recordReceivedWebSessionTabsSnapshot,
sessionTabsFreshnessKey,
@@ -117,7 +117,7 @@ export function handleGlobalSessionEvent(args: GlobalSessionEventArgs): void {
)
}
let settleHydration: HostSessionMirrorSettle | null = null
void recoverWebSessionTerminalOrphansBeforeApply(useAppStore.getState(), event, environmentId, {
void recoverMirroredTerminalOrphansBeforeApply(useAppStore.getState(), event, environmentId, {
expectedEnvironmentPairingRevision,
expectedRuntimeId: runtimeId,
getCurrentState: () => useAppStore.getState()
@@ -2,7 +2,7 @@ import { toRuntimeExecutionHostId } from '../../../../shared/execution-host'
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import { recheckUnconfirmedStructuredAgentLaunches } from '../../lib/structured-agent-session-launch-unconfirmed-recheck'
import { useAppStore } from '../../store'
import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery'
import { recoverMirroredTerminalOrphansBeforeApply } from './mirror-terminal-orphan-recovery'
import { queueAcceptedWebSessionTerminalSnapshot } from '../web-session-terminal-handle-events'
import {
recordReceivedWebSessionTabsInventory,
@@ -88,7 +88,7 @@ export function handleGlobalSessionInventoryEvent({
event.snapshots.map((snapshot, index) =>
unchanged[index]
? Promise.resolve(snapshot)
: recoverWebSessionTerminalOrphansBeforeApply(
: recoverMirroredTerminalOrphansBeforeApply(
useAppStore.getState(),
snapshot,
environmentId,
@@ -1,4 +1,6 @@
import { useAppStore } from '../../store'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { isFrameDrawnServerWorkspace } from './frame-drawn-server-workspaces'
import { isWebRuntimeInitialTerminalBootstrapInFlight } from '../web-runtime-initial-terminal-bootstrap'
import { dispatchWebRuntimeInitialTerminalBootstrap } from '../web-runtime-initial-terminal-bootstrap-dispatch'
import { shouldApplyRecoveredWebSessionTabsSnapshot } from './tracking'
@@ -36,6 +38,10 @@ export function createInitialTerminalSeeder(
let disposed = false
const decide = (event: SessionTabsStreamEvent, firstHostAnswer: boolean): boolean => {
// Switch point: ORCA_LAYOUT_OWNER_IS_MAIN && — a frame-drawn workspace's tabs are not the mirror's to create.
if (ORCA_LAYOUT_OWNER_IS_MAIN && isFrameDrawnServerWorkspace(environmentId, worktreeId)) {
return false
}
const state = useAppStore.getState()
return shouldBootstrapInitialWebRuntimeTerminal({
event,
@@ -2,7 +2,7 @@ import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import { useAppStore } from '../../store'
import { getRuntimeEnvironmentRevision } from '../runtime-environment-revision'
import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery'
import { recoverMirroredTerminalOrphansBeforeApply } from './mirror-terminal-orphan-recovery'
import {
isSessionTabsListAllResult,
recordReceivedWebSessionTabsSnapshot,
@@ -88,7 +88,7 @@ export function loadInitialWebSessionTabs({
)
const recovered = await Promise.all(
result.snapshots.map((snapshot) =>
recoverWebSessionTerminalOrphansBeforeApply(
recoverMirroredTerminalOrphansBeforeApply(
useAppStore.getState(),
snapshot,
environmentId,
@@ -0,0 +1,16 @@
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery'
import { isFrameDrawnServerWorkspace } from './frame-drawn-server-workspaces'
/** Orphan recovery for frames the mirror reconciles; a frame-drawn workspace's terminals are not the mirror's to adopt. */
export function recoverMirroredTerminalOrphansBeforeApply(
...args: Parameters<typeof recoverWebSessionTerminalOrphansBeforeApply>
): Promise<RuntimeMobileSessionTabsResult | null> {
const [, frame, environmentId] = args
// Switch point: ORCA_LAYOUT_OWNER_IS_MAIN && — recovery claims host surfaces for local tabs frames drew.
if (ORCA_LAYOUT_OWNER_IS_MAIN && isFrameDrawnServerWorkspace(environmentId, frame.worktree)) {
return Promise.resolve(frame)
}
return recoverWebSessionTerminalOrphansBeforeApply(...args)
}
@@ -0,0 +1,150 @@
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import { toWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id'
import { sessionTabsFreshnessKey } from './state'
import { isReadyTerminalTab, isTerminalSurfaceTab } from './terminal-surfaces'
import { isWebSessionTabsWorktreeRemovalFrame } from './session-tabs-inventory-absence'
type WorktreeSurfaces = {
environmentId: string
worktreeId: string
/** Local ids of every terminal tab the latest snapshot published, ready or not. */
tabIds: ReadonlySet<string>
handlesBySurface: ReadonlyMap<string, string>
}
type HandlesChangedListener = (environmentId: string, worktreeId: string) => void
const surfacesByWorktree = new Map<string, WorktreeSurfaces>()
const listeners = new Set<HandlesChangedListener>()
const pendingNotifications = new Map<string, { environmentId: string; worktreeId: string }>()
let notificationScheduled = false
const NO_TAB_IDS: ReadonlySet<string> = new Set()
function surfaceKey(hostParentTabId: string, leafId: string): string {
return `${hostParentTabId}\0${leafId}`
}
function sameHandles(a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): boolean {
if (a.size !== b.size) {
return false
}
for (const [key, handle] of a) {
if (b.get(key) !== handle) {
return false
}
}
return true
}
// Why a microtask: snapshots are indexed inside the store updater, and listeners read the committed store.
function notifyHandlesChanged(environmentId: string, worktreeId: string): void {
pendingNotifications.set(sessionTabsFreshnessKey(environmentId, worktreeId), {
environmentId,
worktreeId
})
if (notificationScheduled) {
return
}
notificationScheduled = true
queueMicrotask(() => {
notificationScheduled = false
const batch = [...pendingNotifications.values()]
pendingNotifications.clear()
for (const { environmentId: env, worktreeId: wt } of batch) {
for (const listener of listeners) {
try {
listener(env, wt)
} catch (error) {
console.warn('[web-session-tabs-sync] terminal handle listener failed:', error)
}
}
}
})
}
/** Records one snapshot's terminal surfaces and returns the tab ids the previous one published. */
export function indexMirroredTerminalSurfaces(
environmentId: string,
snapshot: RuntimeMobileSessionTabsResult
): ReadonlySet<string> {
const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree)
const previous = surfacesByWorktree.get(key)
if (isWebSessionTabsWorktreeRemovalFrame(snapshot)) {
forgetWorktree(key, previous)
return previous?.tabIds ?? NO_TAB_IDS
}
const tabIds = new Set<string>()
const handlesBySurface = new Map<string, string>()
for (const tab of snapshot.tabs) {
if (!isTerminalSurfaceTab(tab)) {
continue
}
tabIds.add(toWebTerminalSurfaceTabId(tab.parentTabId))
if (isReadyTerminalTab(tab)) {
handlesBySurface.set(surfaceKey(tab.parentTabId, tab.leafId), tab.terminal)
}
}
surfacesByWorktree.set(key, {
environmentId,
worktreeId: snapshot.worktree,
tabIds,
handlesBySurface
})
if (!sameHandles(previous?.handlesBySurface ?? new Map(), handlesBySurface)) {
notifyHandlesChanged(environmentId, snapshot.worktree)
}
return previous?.tabIds ?? NO_TAB_IDS
}
function forgetWorktree(key: string, entry: WorktreeSurfaces | undefined): void {
if (!entry) {
return
}
surfacesByWorktree.delete(key)
if (entry.handlesBySurface.size > 0) {
notifyHandlesChanged(entry.environmentId, entry.worktreeId)
}
}
/** Drops a worktree's index, or every worktree of the environment when none is named. */
export function forgetMirroredTerminalHandles(environmentId: string, worktreeId?: string): void {
if (worktreeId !== undefined) {
const key = sessionTabsFreshnessKey(environmentId, worktreeId)
forgetWorktree(key, surfacesByWorktree.get(key))
return
}
for (const [key, entry] of surfacesByWorktree) {
if (entry.environmentId === environmentId) {
forgetWorktree(key, entry)
}
}
}
/** The ready handle of a host terminal surface, from the latest session.tabs snapshot; null when none. */
export function mirroredTerminalHandle(
environmentId: string,
worktreeId: string,
hostParentTabId: string,
leafId: string
): string | null {
return (
surfacesByWorktree
.get(sessionTabsFreshnessKey(environmentId, worktreeId))
?.handlesBySurface.get(surfaceKey(hostParentTabId, leafId)) ?? null
)
}
/** Fires after a snapshot changed any handle in the index for that worktree. */
export function onMirroredTerminalHandlesChanged(listener: HandlesChangedListener): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
export function resetMirroredTerminalHandlesForTests(): void {
surfacesByWorktree.clear()
listeners.clear()
pendingNotifications.clear()
}
@@ -26,6 +26,10 @@ import {
} from '../web-runtime-initial-terminal-bootstrap'
import { clearWebSessionReorderIntentsForWorktree } from '../web-session-reorder-intent'
import { clearWebSessionCloseIntentsForWorktree } from '../web-session-close-intent'
import {
forgetMirroredTerminalHandles,
resetMirroredTerminalHandlesForTests
} from './mirrored-terminal-handle-index'
import {
forgetRetiredEpochCensuses,
resetRetiredEpochCensusesForTests
@@ -97,6 +101,7 @@ export function resetWebSessionTabsSnapshotFreshnessForTests(): void {
sessionTabsTrackingGenerations.reset()
resetWebSessionBrowserPlacementsForTests()
resetRetiredEpochCensusesForTests()
resetMirroredTerminalHandlesForTests()
}
export function _getWebSessionTabsTrackingCountsForTest(): {
@@ -149,6 +154,7 @@ export function clearWebSessionTabsTrackingForWorktree(
clearHostSessionTabIdMappings(environmentId, worktreeId)
clearWebSessionBrowserPlacementsForWorktree(environmentId, worktreeId)
clearWebSessionTerminalPlacementsForWorktree(environmentId, worktreeId)
forgetMirroredTerminalHandles(environmentId, worktreeId)
}
export function clearWebSessionTabsTrackingForEnvironment(environmentId: string): void {
@@ -218,6 +224,7 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string)
clearHostMirrorHandleGapVerdictsForEnvironment(trimmedEnvironmentId)
clearAllWebRuntimeWakeTerminalRespawn()
clearWebRuntimeInitialTerminalBootstrapsForEnvironment(trimmedEnvironmentId)
forgetMirroredTerminalHandles(trimmedEnvironmentId)
}
export function getWebSessionTabsTrackingGeneration(environmentId: string): number {
@@ -23,6 +23,7 @@ export function framesDrawWorkspace(state: AppState): (key: string) => boolean {
*/
export function onWorkspaceStartsDrawing(
store: HeldWorkspacesStore,
draws: (state: AppState) => (key: string) => boolean,
unshown: () => string[],
onDrawn: () => void
): () => void {
@@ -35,8 +36,7 @@ export function onWorkspaceStartsDrawing(
return
}
held = state
const drawn = framesDrawWorkspace(state)
if (unshown().some(drawn)) {
if (unshown().some(draws(state))) {
onDrawn()
}
})
@@ -18,7 +18,6 @@ import { applyLayoutFrame, EMPTY_WORKSPACE_LAYOUT_CACHE } from '@/store/workspac
import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache'
import { terminalLayoutsPatch } from '@/store/workspace-layout-frame-store-patch'
import {
framesDrawWorkspace,
layoutHoldsBoundPane,
onWorkspaceStartsDrawing
} from './workspace-layout-frame-drawn-workspaces'
@@ -39,11 +38,6 @@ import {
type PendingWorkspaceChanges
} from './workspace-layout-frame-pending'
import { layoutFrameApplyObserver } from './workspace-layout-frame-apply-observer'
import {
beginLayoutFramePositions,
endLayoutFramePositions,
noteLayoutFramePosition
} from './layout-frame-position'
import {
provideLayoutFrameTerminalPanes,
type LayoutFrameTerminalQueries
@@ -58,7 +52,11 @@ import {
isDrawnTabHeld,
onDrawnTabsRedraw
} from './layout-owner-drawn-tab-holds'
import { subscribeRuntimeLayout } from './runtime-layout-client'
import {
LOCAL_LAYOUT_FRAMES,
openLayoutFrameStream,
type LayoutFrameSource
} from './layout-frame-stream'
type FrameSyncStore = {
getState: () => AppState
@@ -70,9 +68,16 @@ export type WorkspaceLayoutFrameSync = LayoutFrameTerminalQueries & {
/** Shows every workspace the frames own once the snapshot is in, then keeps them current. */
start: () => void
close: () => void
/** A frame this window made from what it already holds (a paired server's new handles). */
receive: (frame: WorkspaceLayoutChangeFrame) => void
/** These workspaces have just started drawing from frames: each takes the full build. */
redraw: (keys: Iterable<string>) => void
}
export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLayoutFrameSync {
export function openWorkspaceLayoutFrameSync(
store: FrameSyncStore,
source: LayoutFrameSource = LOCAL_LAYOUT_FRAMES
): WorkspaceLayoutFrameSync {
let cache: WorkspaceLayoutCache = EMPTY_WORKSPACE_LAYOUT_CACHE
/** Workspaces the store shows from frames; any other the frames draw takes a full build. */
const shown = new Set<string>()
@@ -118,7 +123,7 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
state.terminalLayoutsByTabId,
cache,
paneTreeKeys,
framesDrawWorkspace(state)
source.draws(state)
)
paneTreeKeys = shownTrees.waiting
show(terminalLayoutsPatch(state, shownTrees.layouts))
@@ -148,7 +153,7 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
return
}
const state = store.getState()
const drawn = framesDrawWorkspace(state)
const drawn = source.draws(state)
const rowFieldsTaken = new Map<string, Partial<TerminalTab>>()
const holds: LayoutFrameBuildHolds = {
...FRAME_BUILD_HOLDS,
@@ -255,37 +260,20 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
scheduleFlush()
}
beginLayoutFramePositions()
const stream = subscribeRuntimeLayout(
{ kind: 'local' },
{},
(frame) => {
const before = cache
cache = applyLayoutFrame(cache, frame)
noteFrame(before, frame)
if (frame.type === 'snapshot') {
snapshotReceived = true
live = started
}
scheduleFlush()
},
(status) => {
if (status.state === 'lost' || status.state === 'refused') {
console.warn('[layout-owner] layout stream stopped:', status)
}
if (status.state === 'connecting' && !streamOpen) {
// The local stream reopens after a loss; replies wait for its frames again.
streamOpen = true
beginLayoutFramePositions()
} else if (status.state !== 'connecting' && status.state !== 'live') {
streamOpen = false
endLayoutFramePositions()
}
},
// After the frame's flush. A held tab may still show as the window drew it, so a waiting reply
// checks the store rather than assume the frame shows.
(seq) => queueMicrotask(() => streamOpen && noteLayoutFramePosition(seq))
)
const receive = (frame: WorkspaceLayoutChangeFrame): void => {
const before = cache
cache = applyLayoutFrame(cache, frame)
noteFrame(before, frame)
if (frame.type === 'snapshot') {
snapshotReceived = true
live = started
}
scheduleFlush()
}
const stream = openLayoutFrameStream(source, receive, (open) => {
streamOpen = open
})
const stopHeldTabsListener = onDrawnTabsRedraw((workspaces) => {
if (streamOpen) {
rebuildWorkspaces(workspaces)
@@ -308,6 +296,7 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
// activation finds main's tab rather than seeding a second one.
const stopHeldWorkspacesListener = onWorkspaceStartsDrawing(
store,
source.draws,
() => Object.keys(cache).filter((key) => !shown.has(key)),
() => streamOpen && scheduleFlush()
)
@@ -321,6 +310,15 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
shown.clear()
flush()
},
receive,
redraw: (keys) => {
for (const key of keys) {
shown.delete(key)
}
if (streamOpen) {
scheduleFlush()
}
},
close: () => {
live = false
stopHeldWorkspacesListener()
@@ -330,8 +328,7 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa
},
// Only a pane flush draws: the same workspaces it shows.
holdsTerminalPane: (worktreeId, pane) =>
framesDrawWorkspace(store.getState())(worktreeId) &&
layoutHoldsBoundPane(cache[worktreeId], pane),
source.draws(store.getState())(worktreeId) && layoutHoldsBoundPane(cache[worktreeId], pane),
// A closed stream's cache is frozen: main may have closed the tab since.
terminalTab: (worktreeId, tabId) =>
streamOpen
@@ -0,0 +1,452 @@
/**
* A switch-on desktop paired to a switch-on `orca serve` draws the server's workspace from the
* server's layout stream (H5b), not the session.tabs mirror: its tree equals the server's own by
* tab and pane ids and order, its terminal panes bind the session.tabs handle, and both hold
* through a serve restart and a layout change made on the server's side.
*
* Run (switch-on build: ORCA_LAYOUT_OWNER=main ... --mode e2e):
* pnpm exec playwright test tests/e2e/paired-desktop-server-layout-frames.spec.ts \
* --config tests/playwright.config.ts --project electron-headless --workers=2
*/
import { existsSync, rmSync } from 'node:fs'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import { pairedRuntimeConnectionFromOffer } from '../../src/shared/paired-runtime-connection'
import { decodePairingOffer } from '../../src/shared/pairing'
import { subscribeRemoteRuntimeRequest } from '../../src/shared/remote-runtime-client'
import { toRemoteRuntimePtyId } from '../../src/shared/remote-runtime-pty-id'
import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types'
import {
HOST_TERMINAL_SURFACE_SEPARATOR,
toWebTerminalSurfaceTabId
} from '../../src/shared/terminal-surface-id'
import { LAYOUT_INTENTS_RUNTIME_CAPABILITY } from '../../src/shared/workspace-layout/layout-intents-capability'
import type { PublishedWorkspaceLayout } from '../../src/shared/workspace-layout/workspace-layout-published'
import { readWorkspaceLayoutStreamFrame } from '../../src/shared/workspace-layout/workspace-layout-stream-frames'
import { expect, test } from './helpers/orca-app'
import {
launchHeadlessPairedRuntimeHost,
type HeadlessPairedRuntimeHost
} from './helpers/headless-paired-runtime-host'
import {
createHostCliTerminal,
createRetentionFixtureDirectory,
writeRetentionFixture
} from './helpers/host-created-terminal-retention-oracle'
import {
launchPairedElectronClient,
type PairedElectronClient
} from './helpers/paired-electron-client'
import { readPairedPaneContent } from './helpers/paired-host-terminal'
import { leafIdsInOrder } from './helpers/workspace-layout-oracle-model'
const LAYOUT_OWNER_BUILD = existsSync(
path.join(process.cwd(), 'out', 'main', 'ORCA_LAYOUT_OWNER_MAIN')
)
/** Groups in order, each tab in tab order with its pane ids in tree order. */
type Tree = { id: string; tabs: { id: string; kind: string; panes: string[] }[] }[]
const scratch = createRetentionFixtureDirectory()
const fixturePath = writeRetentionFixture(scratch)
const sinkPath = path.join(scratch, 'server-layout-frames.log')
test.afterAll(() => {
rmSync(scratch, { recursive: true, force: true })
})
/** The server's own layout of the workspace: the first snapshot of a stream of the test's own. */
async function readServerLayout(
host: HeadlessPairedRuntimeHost,
worktreeId: string
): Promise<PublishedWorkspaceLayout | null> {
let resolveSnapshot: (value: unknown) => void = () => {}
const snapshot = new Promise<unknown>((resolve) => {
resolveSnapshot = resolve
})
const subscription = await subscribeRemoteRuntimeRequest<unknown>(
pairedRuntimeConnectionFromOffer(decodePairingOffer(host.offer.pairingUrl)),
'layout.subscribe',
{ workspaces: [worktreeId] },
30_000,
{
onResponse: (response) => resolveSnapshot(response.ok ? response.result : null),
onError: () => resolveSnapshot(null),
onClose: () => resolveSnapshot(null)
}
)
try {
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 10_000))
const frame = readWorkspaceLayoutStreamFrame(await Promise.race([snapshot, timeout]))
if (frame?.type !== 'snapshot') {
return null
}
return frame.workspaces.find((entry) => entry.key === worktreeId)?.layout ?? null
} finally {
subscription.close()
}
}
/** The server's tree under the ids the desktop gives it: a terminal tab is named by its terminal. */
function serverTree(layout: PublishedWorkspaceLayout | null): Tree | null {
if (!layout) {
return null
}
const tabsById = new Map(layout.tabs.map((tab) => [tab.id, tab]))
return layout.groups.map((group) => ({
id: group.id,
tabs: group.tabIds.map((tabId) => {
const tab = tabsById.get(tabId)
if (!tab) {
return { id: tabId, kind: 'missing', panes: [] }
}
const terminal = tab.kind === 'terminal'
return {
id: terminal ? toWebTerminalSurfaceTabId(tab.entityId) : tab.id,
kind: tab.kind,
panes: terminal ? leafIdsInOrder(tab.terminal?.root) : []
}
})
}))
}
async function desktopTree(page: Page, worktreeId: string): Promise<Tree> {
return page.evaluate((id) => {
const state = window.__store!.getState()
const tabs = state.unifiedTabsByWorktree[id] ?? []
type Node = { type: 'leaf'; leafId: string } | { type: 'split'; first: Node; second: Node }
const leaves = (node: Node | null | undefined): string[] =>
!node
? []
: node.type === 'leaf'
? [node.leafId]
: [...leaves(node.first), ...leaves(node.second)]
return (state.groupsByWorktree[id] ?? []).map((group) => ({
id: group.id,
tabs: group.tabOrder.map((tabId) => {
const tab = tabs.find((candidate) => candidate.id === tabId)
if (!tab) {
return { id: tabId, kind: 'missing', panes: [] }
}
return {
id: tab.id,
kind: tab.contentType,
panes:
tab.contentType === 'terminal'
? leaves(state.terminalLayoutsByTabId[tab.entityId]?.root)
: []
}
})
}))
}, worktreeId)
}
/** True when frames draw it; else what the desktop knows of the server, for the failure message. */
async function isFrameDrawn(
client: PairedElectronClient,
worktreeId: string
): Promise<true | { hook: boolean; verification: unknown; layoutIntents: string[] }> {
return client.page.evaluate(
({ environmentId, worktreeId }) => {
const hook = window.__isFrameDrawnServerWorkspaceE2E
if (hook?.(environmentId, worktreeId)) {
return true
}
const status = window.__store?.getState().runtimeStatusByEnvironmentId.get(environmentId)
const capabilities = status?.snapshot?.status?.capabilities ?? []
return {
hook: hook !== undefined,
verification: status?.snapshot?.verification ?? null,
layoutIntents: capabilities.filter((entry) => entry.startsWith('layout'))
}
},
{ environmentId: client.environmentId, worktreeId }
)
}
/** Polls until the workspace is frame-drawn and both trees agree three reads in a row. */
async function expectFrameDrawnTreeMatchesServer(
host: HeadlessPairedRuntimeHost,
client: PairedElectronClient,
worktreeId: string,
message: string
): Promise<Tree> {
let streak = 0
let settled: Tree | null = null
await expect
.poll(
async () => {
const [server, desktop, frameDrawn] = await Promise.all([
readServerLayout(host, worktreeId).then(serverTree),
desktopTree(client.page, worktreeId),
isFrameDrawn(client, worktreeId)
])
const match =
frameDrawn === true &&
server !== null &&
JSON.stringify(server) === JSON.stringify(desktop)
streak = match ? streak + 1 : 0
settled = match ? server : null
return streak >= 3 ? 'settled' : { frameDrawn, server, desktop }
},
{ timeout: 90_000, intervals: [500], message }
)
.toBe('settled')
return settled!
}
async function readHostHandles(
host: HeadlessPairedRuntimeHost,
worktreeId: string,
hostTabId: string
): Promise<Record<string, string | null>> {
const listed = await host.client.call<RuntimeMobileSessionTabsResult>('session.tabs.list', {
worktree: `id:${worktreeId}`
})
return Object.fromEntries(
listed.result.tabs.flatMap((tab) =>
tab.type === 'terminal' && tab.parentTabId === hostTabId
? [[tab.leafId, tab.status === 'ready' ? tab.terminal : null]]
: []
)
)
}
/** Each pane of the tab binds `remote:` + the handle session.tabs gives its surface now. */
async function expectPanesBoundToHandles(
host: HeadlessPairedRuntimeHost,
client: PairedElectronClient,
worktreeId: string,
hostTabId: string,
leafIds: string[]
): Promise<Record<string, string>> {
const webTabId = toWebTerminalSurfaceTabId(hostTabId)
let expected: Record<string, string> = {}
await expect
.poll(
async () => {
const handles = await readHostHandles(host, worktreeId, hostTabId)
expected = Object.fromEntries(
leafIds.map((leafId) => {
const handle = handles[leafId]
return [leafId, handle ? toRemoteRuntimePtyId(handle, client.environmentId) : 'none']
})
)
const bound = await client.page.evaluate(
({ webTabId, leafIds }) => {
const state = window.__store!.getState()
const bindings = state.terminalLayoutsByTabId[webTabId]?.ptyIdsByLeafId ?? {}
const manager = window.__paneManagers?.get(webTabId)
const mounted = Object.fromEntries(
(manager?.getPanes?.() ?? []).map((pane) => [
pane.container.dataset.leafId ?? '',
pane.container.dataset.ptyId ?? null
])
)
return Object.fromEntries(
leafIds.map((leafId) => [
leafId,
{ binding: bindings[leafId] ?? null, pane: mounted[leafId] ?? null }
])
)
},
{ webTabId, leafIds }
)
return Object.fromEntries(
Object.entries(expected).map(([leafId, ptyId]) => [
leafId,
bound[leafId]?.binding === ptyId &&
bound[leafId]?.pane === ptyId &&
ptyId.startsWith('remote:')
? 'bound'
: { want: ptyId, ...bound[leafId] }
])
)
},
{ timeout: 90_000, message: 'Desktop panes never bound the session.tabs handles' }
)
.toEqual(Object.fromEntries(leafIds.map((leafId) => [leafId, 'bound'])))
return expected
}
async function openTab(client: PairedElectronClient, worktreeId: string, webTabId: string) {
await client.page.evaluate(
({ environmentId, worktreeId, webTabId }) => {
const state = window.__store!.getState()
state.setActiveView('terminal')
state.setActiveWorktree(worktreeId, `runtime:${environmentId}`)
state.setActiveTab(webTabId)
},
{ environmentId: client.environmentId, worktreeId, webTabId }
)
await expect
.poll(() => client.page.evaluate((id) => window.__paneManagers?.has(id) ?? false, webTabId), {
timeout: 60_000,
message: 'The frame-drawn terminal tab never mounted'
})
.toBe(true)
}
/** Types into the pane showing `leafId`, through its xterm input as a user does. */
async function typeIntoPane(
page: Page,
webTabId: string,
leafId: string,
text: string
): Promise<void> {
await page.evaluate(
({ webTabId, leafId }) => {
const manager = window.__paneManagers?.get(webTabId)
const pane = manager?.getPanes?.().find((entry) => entry.container.dataset.leafId === leafId)
const input = pane?.container.querySelector('.xterm-helper-textarea')
if (!pane || !(input instanceof HTMLTextAreaElement)) {
throw new Error(`pane ${leafId} has no terminal input`)
}
pane.terminal.focus()
input.focus()
},
{ webTabId, leafId }
)
await page.keyboard.type(text)
await page.keyboard.press('Enter')
}
async function readPaneText(page: Page, webTabId: string, leafId: string): Promise<string> {
return page.evaluate(
({ webTabId, leafId }) => {
const pane = window.__paneManagers
?.get(webTabId)
?.getPanes?.()
.find((entry) => entry.container.dataset.leafId === leafId)
return pane?.serializeAddon?.serialize?.() ?? ''
},
{ webTabId, leafId }
)
}
async function waitForWorktree(host: HeadlessPairedRuntimeHost, repoId: string): Promise<string> {
let worktreeId = ''
await expect
.poll(
async () => {
const listed = await host.client.call<{ worktrees: { id: string }[] }>('worktree.list', {
repo: `id:${repoId}`
})
worktreeId = listed.result.worktrees[0]?.id ?? ''
return worktreeId
},
{ timeout: 30_000, message: 'Serve host never listed its folder workspace' }
)
.not.toBe('')
return worktreeId
}
test.describe('paired desktop draws a switch-on server from its layout frames', () => {
test.skip(!LAYOUT_OWNER_BUILD, 'Needs a layout-owner build (ORCA_LAYOUT_OWNER=main)')
test('frames draw the server workspace; handles attach; restart and server changes keep it', async ({
testRepoPath
}, testInfo) => {
test.setTimeout(480_000)
const host = await launchHeadlessPairedRuntimeHost({ pinnedServePort: true })
let client: PairedElectronClient | null = null
try {
const status = await host.client.call<{ capabilities?: string[] }>('status.get')
expect(status.result.capabilities ?? []).toContain(LAYOUT_INTENTS_RUNTIME_CAPABILITY)
const added = await host.client.call<{ repo: { id: string } }>('repo.add', {
path: testRepoPath,
kind: 'folder'
})
const worktreeId = await waitForWorktree(host, added.result.repo.id)
const created = await createHostCliTerminal(
async <TResult>(method: string, params: unknown) =>
(await host.client.call<TResult>(method, params)).result,
worktreeId,
fixturePath,
sinkPath
)
// A second pane, so the pane order is part of what must match.
await host.client.call('terminal.split', { terminal: created.handle, direction: 'vertical' })
const hostTabId = created.tabId.split(HOST_TERMINAL_SURFACE_SEPARATOR)[0]!
const webTabId = toWebTerminalSurfaceTabId(hostTabId)
client = await launchPairedElectronClient(host.offer, testInfo, 'server-layout-frames')
const desktop = client
await expect
.poll(
() =>
desktop.page.evaluate(
(id) =>
window.__store
?.getState()
.allWorktrees()
.some((worktree) => worktree.id === id) ?? false,
worktreeId
),
{ timeout: 60_000, message: 'Paired desktop never listed the server workspace' }
)
.toBe(true)
await openTab(desktop, worktreeId, webTabId)
let leafIds: string[] = []
await test.step('frames are the source, and the tree equals the server layout', async () => {
const tree = await expectFrameDrawnTreeMatchesServer(
host,
desktop,
worktreeId,
'Desktop never drew the server workspace from frames with the server tree'
)
const tab = tree.flatMap((group) => group.tabs).find((entry) => entry.id === webTabId)
expect(tab?.panes).toHaveLength(2)
expect(tab?.panes).toContain(created.leafId)
leafIds = tab!.panes
})
await test.step('the terminal works through the session.tabs handle', async () => {
await expectPanesBoundToHandles(host, desktop, worktreeId, hostTabId, leafIds)
await typeIntoPane(desktop.page, webTabId, created.leafId, 'H5B_BEFORE_RESTART')
await expect
.poll(() => readPaneText(desktop.page, webTabId, created.leafId), { timeout: 30_000 })
.toContain('LINE:H5B_BEFORE_RESTART')
})
await test.step('after a serve restart both sides show the same tree again', async () => {
await host.restartServeProcess()
await expectFrameDrawnTreeMatchesServer(
host,
desktop,
worktreeId,
'After the serve restart the desktop tree never matched the server from frames'
)
// The tree, not scrollback: a forced quit can take the dev daemon, and its shell, with it.
const bound = await expectPanesBoundToHandles(host, desktop, worktreeId, hostTabId, leafIds)
expect(Object.values(bound).every((ptyId) => ptyId.startsWith('remote:'))).toBe(true)
await typeIntoPane(desktop.page, webTabId, created.leafId, 'H5B_AFTER_RESTART')
await expect
.poll(() => readPairedPaneContent(desktop.page, webTabId), { timeout: 30_000 })
.toContain('H5B_AFTER_RESTART')
})
await test.step('a tab the server creates shows on the desktop from frames', async () => {
const createdOnServer = await host.client.call<{ tab: { id: string } }>(
'session.tabs.createTerminal',
{ worktree: `id:${worktreeId}`, activate: false, select: false, navigation: 'caller' }
)
const newWebTabId = toWebTerminalSurfaceTabId(
createdOnServer.result.tab.id.split(HOST_TERMINAL_SURFACE_SEPARATOR)[0]!
)
const tree = await expectFrameDrawnTreeMatchesServer(
host,
desktop,
worktreeId,
'The server-created tab never showed on the desktop from frames'
)
expect(tree.flatMap((group) => group.tabs.map((tab) => tab.id))).toContain(newWebTabId)
})
} finally {
await client?.dispose()
await host.dispose()
}
})
})