Layout-owner build: phones see main's terminal and editor rows (A2, dark) (#31276)

* wip(layout): applyDefaultTabs command in the layout model (dark)

* wip(layout): applyDefaultTabs takes the window's proposed ids; main refuses a taken one

* wip(layout): main closes or keeps an exited split pane by the window's rule (dark)

* wip(layout): a create or split sent again after its close is refused (dark)

* wip: typed test doubles

* wip(layout): main writes a worktree's default tabs in one command and starts them, window or none (dark; twin e)

* wip(layout): main writes a worktree's default tabs in one command and starts them, window or none (dark; twin e)

* wip(layout): main provisions setup and default tabs even when the agent fails to start; no window takes over (dark)

* wip(layout): pane start status in frames; a failed start keeps its pane; a terminal close stands after a failed write (dark)

* wip: lint fixes (max-lines, test throws)

* test(layout): a terminal close stands after a real failed profile write; switch off still refuses

* fix(layout): checked types in the close-failure rule and its tests

* fix(layout): a failed start keeps its pane whatever failed; default tabs start once; a tab's last pane keeps its card (dark)

* test(layout): the agent's kept pane counts as the workspace's terminal; main starts it whatever failed

* fix(layout): the switch-off create path gains no await

* fix(layout): a failed start never writes back a pane the user closed (dark)

* fix(layout): a failed start's pane goes through main's create rules, so a closed tab stays closed (dark)

* test(layout): D1a's switch-on close tests assert the intent main receives (both builds)

* test(layout): the close fixture answers main's command without naming the method

* fix(layout): a re-created pane binds as designed; the id-only ratchet reads a shared import (dark)

* fix(layout): a pane's start status may sit on a pane still bound to its old terminal (dark)

* fix(layout): a pane's start status ends when its pane binds again by any path (dark)

* test(layout): same-id restore ends a status; a replayed split brings back a closed pane by design

* test(layout): terminal reply fixtures captured from the window-owned build, and main's switch-on proofs

* wip(layout): main's terminal surface index feeds the runtime graph (dark)

* wip(layout): main's panes stand in for the window's terminal list (dark)

* test(layout): the window sends main-listed panes as mount facts (dark)

* test(layout): surface proofs use public runtime calls

* chore: lint

* feat(layout): close by handle closes in main's layout; the close-time relist goes (dark)

* feat(layout): main's binding outranks a caller's pane claim on the terminal record (dark)

* test(layout): window-list tests stay on the rollback build; switch-on mount-fact cases (dark)

* wip(layout): main's terminal index reads E1's shared projection and follows its operation ends (dark)

* refactor(layout): the main-surfaces host lives beside the session-tabs readers, keeping the graph file under its line cap (dark)

* fix(layout): main's panes skip the window-era membership fence, follow workspace routing, and list a terminal when it registers; window close only hands the graph back (dark)

* refactor(runtime): explicit-close PTY stops move unchanged to their own module, for the file's line cap

* test(layout): the cold-restore table runs in a layout-owner build too: one push per registration

* test(layout): the parked and automation pane tests assert the same window report in both builds (mount facts with the switch on)

* chore: one layout-owner switch import after the restack on D1a

* feat(layout): main's terminal.list rows carry the host's verdict; a pane with a status is never live (dark)

* chore(layout): fit main's surfaces under the lint gates after the D1a restack

* feat(layout): every pane status D1a publishes keeps a row from reading live; remote host answers pass through (dark)

* feat(layout): a drag-out moves main's own binding and refuses the home copy's last pane; the window's terminal claim is not consulted (L127, dark)

* fix(layout): a tab close by a starting pane's handle stops the tab's terminals; a starting pane is never exited on the inventory alone; terminal.list lists every saved pane main owns; the pane claim lookup leaves the output path (dark)

* fix(terminal): keep shipped exit and default-tab behaviour with the layout switch on

An exited pane closes through the legacy retirement unless the keep rule holds; the
default tab's title and color still reach the window and the phone's live tabs.

* fix(terminal): one id guard for every pane write; keep panes on an unvouched -1

Main's taken and closed-id guard now runs where every pane write goes, so a runtime create
replayed after its tab closed is refused before it spawns. An exit code nothing vouches for
keeps the pane, as the window does; a kept pane sends the phone no retirement. Converts the
switch-on tests that asserted the deleted window hand-off and title bridge.

* fix(terminal): keep the closed-tab guard on every pane write, held-id checks on client commands

A paired host starts each pane of a client's tab as a create under the tab's id, so a held tab
id is refused only for a client's own layout command.

* fix(terminal): a provider-reported -1 keeps the pane, as the window does

Only a requested stop or a host-confirmed death closes a pane on a negative exit code. Pins that
the closed-tab guard never fires in the shipped build.

* chore(terminal): satisfy the exhaustive-switch and test typing checks

* test(terminal): give the replay test's live process a cwd

* fix(layout): main's index reads routing only for a workspace two partitions hold, the fence reads ownership once, a legacy pane keeps its claim; switch-on tests assert main's answers after a window closes (dark)

* refactor(runtime): a worktree stop's terminal collection moves unchanged to its own module, for the file's line cap

* fix(layout): main's index routes every workspace as the layout stream does, re-reading routing only on a write or a terminal registration; a worktree stop names every terminal main binds; a starting pane is unverifiable (dark)

* feat(layout): a workspace main owns publishes its terminal and editor session.tabs rows from main's layout; the window still supplies browser, chat and view-only rows until D3b (A2, dark)

* chore: lint

* test: type the row fixtures

* chore(layout): main's session.tabs projection is an old-client entry, imported only by main's terminal surface index

* fix(layout): main's session.tabs rows carry the tab's name, the window's agent status and markdown file id, list rows in tab order, and keep groups and selection consistent (A2, dark)

* refactor(layout): the projection's group order moves unchanged to its own old-client module, for the file's line cap; lint

* feat(layout): every pane status main publishes keeps a row from reading live; a resuming pane is unverifiable like a starting one (dark)

* test(layout): a windowless server's layout change reaches a subscribed phone's session.tabs rows (dark)

* fix(layout): a window-only row keeps its place in the rows, the window's selected row lands in a group that selects it, and a workspace with no main group keeps the window's groups (A2, dark)

* fix(layout): a pane main has no binding for yet keeps the window's terminal in its phone row, so an SSH restore lists it pending (A2, dark; ledger L243)

* fix(session-tabs): phones show the window's live selection, focused pane, dirty state and titles over main's saved view; exited terminals stay gone; stray rows join the active group (switch-on builds only)

* fix(session-tabs): main's groups stand with main's selection; certified exits only; a selected stray row joins main's active group

---------

Co-authored-by: E2E Test <e2e@test.local>
This commit is contained in:
Jinwoo Hong
2026-10-20 22:10:25 -04:00
committed by GitHub
co-authored by E2E Test
parent 8e5992663b
commit 6e589bb4ba
11 changed files with 820 additions and 19 deletions
@@ -10,8 +10,11 @@ import { isTestOnlySourcePath } from './test-only-source-path.mjs'
export const COMPAT_FOLDERS = [
{
folder: 'src/shared/workspace-layout/old-client',
entries: ['old-client-session-tab-rows'],
importers: ['src/main/runtime/terminal/launch/mobile-terminal-chat-fork-create.ts']
entries: ['old-client-session-tab-rows', 'old-client-session-tabs-projection'],
importers: [
'src/main/runtime/terminal/launch/mobile-terminal-chat-fork-create.ts',
'src/main/runtime/terminal/surface/terminal-surface-index.ts'
]
},
{
folder: 'src/main/remote-layout-snapshot',
@@ -0,0 +1,317 @@
import { describe, expect, it } from 'vitest'
import type {
RuntimeMobileSessionSnapshotTab,
RuntimeMobileSessionTerminalTab
} from '../../../shared/runtime-mobile-session-tab-contracts'
import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-session-contracts'
import { withMainSessionTabRows, type MainSessionTabRows } from './main-session-tab-rows'
const WT = 'repo::/wt'
const NONE_EXITED = (): boolean => false
function terminal(
id: string,
extra: Partial<RuntimeMobileSessionTerminalTab> = {}
): RuntimeMobileSessionTerminalTab {
return {
type: 'terminal' as const,
id,
title: 'Terminal',
parentTabId: id.split('::')[0]!,
leafId: id.split('::')[1]!,
isActive: false,
...extra
}
}
function file(id: string, mode: 'edit' | 'diff' = 'edit') {
return {
type: 'file' as const,
id,
title: id,
filePath: `/wt/${id}`,
relativePath: id,
language: 'typescript',
mode,
isDirty: false,
isActive: false
}
}
function browser(id: string) {
return {
type: 'browser' as const,
id,
title: 'Page',
browserWorkspaceId: id,
browserPageId: null,
url: 'https://example.com',
loading: false,
canGoBack: false,
canGoForward: false,
isActive: false
}
}
function windowSnapshot(
tabs: RuntimeMobileSessionSnapshotTab[],
tabOrder: string[],
activeTabId: string | null
): RuntimeMobileSessionTabsSnapshot {
const active = tabs.find((tab) => tab.id === activeTabId)
return {
worktree: WT,
publicationEpoch: 'renderer',
snapshotVersion: 7,
activeGroupId: 'g',
activeTabId,
activeTabType: active?.type ?? null,
tabGroups: [{ id: 'g', activeTabId, tabOrder }],
tabs: tabs.map((tab) => ({ ...tab, isActive: tab.id === activeTabId }))
}
}
function mainProjection(
tabs: RuntimeMobileSessionSnapshotTab[],
tabOrder: string[],
activeTabId: string | null
): MainSessionTabRows {
const {
publicationEpoch: _e,
snapshotVersion: _v,
...rest
} = windowSnapshot(tabs, tabOrder, activeTabId)
return rest
}
describe('session.tabs rows from main (layout-owner build, before D3b)', () => {
it("takes main's terminal rows and name, with the window's theme, status and draft", () => {
const theme = { mode: 'dark' as const, theme: {} }
const agentStatus = null
const merged = withMainSessionTabRows(
windowSnapshot(
[
terminal('t::a', {
title: 'Old',
customTitle: 'Old',
terminalTheme: theme,
agentStatus,
launchDraft: 'ls'
})
],
['t'],
't::a'
),
mainProjection(
[terminal('t::a', { title: 'Renamed', customTitle: 'Renamed' }), terminal('t::b')],
['t'],
't::a'
),
NONE_EXITED
)
expect(merged.publicationEpoch).toBe('renderer')
expect(merged.snapshotVersion).toBe(7)
expect(merged.tabs).toEqual([
terminal('t::a', {
title: 'Renamed',
customTitle: 'Renamed',
terminalTheme: theme,
agentStatus,
launchDraft: 'ls',
isActive: true
}),
terminal('t::b')
])
})
it('keeps the rows main does not hold (browser, diff) in the window’s place', () => {
const merged = withMainSessionTabRows(
windowSnapshot(
[terminal('t::a'), browser('b1'), file('d1', 'diff'), file('f1')],
['t', 'b1', 'd1', 'f1'],
't::a'
),
mainProjection([terminal('t::a'), browser('b1'), file('f1')], ['t', 'b1', 'f1'], 't::a'),
NONE_EXITED
)
expect(merged.tabs.map((tab) => tab.id)).toEqual(['t::a', 'b1', 'd1', 'f1'])
expect(merged.tabGroups?.[0]?.tabOrder).toEqual(['t', 'b1', 'd1', 'f1'])
})
// #30569: a closed editor leaves no stale active id behind.
it('drops an editor main closed, and its stale active id, though the window still lists it', () => {
const merged = withMainSessionTabRows(
windowSnapshot([terminal('t::a'), file('f1')], ['t', 'f1'], 'f1'),
mainProjection([terminal('t::a')], ['t'], 't::a'),
NONE_EXITED
)
expect(merged.tabs.map((tab) => tab.id)).toEqual(['t::a'])
expect(merged).toMatchObject({ activeTabId: 't::a', activeTabType: 'terminal' })
expect(merged.tabs.filter((tab) => tab.isActive).map((tab) => tab.id)).toEqual(['t::a'])
expect(merged.tabGroups?.[0]?.tabOrder).toEqual(['t'])
})
it("keeps the window's selection while it shows a row main does not list", () => {
const merged = withMainSessionTabRows(
windowSnapshot([terminal('t::a'), file('d1', 'diff')], ['t', 'd1'], 'd1'),
mainProjection([terminal('t::a')], ['t'], 't::a'),
NONE_EXITED
)
expect(merged).toMatchObject({ activeTabId: 'd1', activeTabType: 'file' })
expect(merged.tabGroups?.[0]?.activeTabId).toBe('d1')
expect(merged.tabs.filter((tab) => tab.isActive).map((tab) => tab.id)).toEqual(['d1'])
})
it('moves the window-selected row of a group main lacks into a group, and selects that group', () => {
const published: RuntimeMobileSessionTabsSnapshot = {
...windowSnapshot([terminal('t::a'), file('d1', 'diff')], ['t'], 'd1'),
activeGroupId: 'g2',
tabGroups: [
{ id: 'g', activeTabId: 't', tabOrder: ['t'] },
{ id: 'g2', activeTabId: 'd1', tabOrder: ['d1'] }
]
}
const merged = withMainSessionTabRows(
published,
mainProjection([terminal('t::a')], ['t'], 't::a'),
NONE_EXITED
)
expect(merged.tabGroups).toEqual([{ id: 'g', activeTabId: 'd1', tabOrder: ['t', 'd1'] }])
expect(merged).toMatchObject({ activeGroupId: 'g', activeTabId: 'd1' })
})
it("takes the window's live selection, focused pane and dirty state, not main's saved view", () => {
const merged = withMainSessionTabRows(
windowSnapshot(
[terminal('t::a'), terminal('t::b', { title: 'vim' }), { ...file('f1'), isDirty: true }],
['t', 'f1'],
't::b'
),
mainProjection([terminal('t::a'), terminal('t::b'), file('f1')], ['t', 'f1'], 't::a'),
NONE_EXITED
)
expect(merged).toMatchObject({ activeTabId: 't::b', activeTabType: 'terminal' })
expect(merged.tabGroups?.[0]?.activeTabId).toBe('t')
expect(merged.tabs.find((tab) => tab.id === 't::b')).toMatchObject({ title: 'vim' })
expect(merged.tabs.find((tab) => tab.id === 'f1')).toMatchObject({ isDirty: true })
})
it("keeps main's selection while the window selects a pane main has not drawn yet", () => {
const merged = withMainSessionTabRows(
windowSnapshot([terminal('t::a'), terminal('t::new')], ['t'], 't::new'),
mainProjection([terminal('t::a')], ['t'], 't::a'),
NONE_EXITED
)
expect(merged).toMatchObject({ activeTabId: 't::a' })
expect(merged.tabs.map((tab) => tab.id)).toEqual(['t::a'])
})
it("lets main's cleared binding win over the window's id of a terminal main saw exit", () => {
const published = windowSnapshot([terminal('t::a', { ptyId: 'pty-gone' })], ['t'], 't::a')
const projected = mainProjection([terminal('t::a')], ['t'], 't::a')
expect(
withMainSessionTabRows(published, projected, (id) => id === 'pty-gone').tabs[0]
).not.toHaveProperty('ptyId')
// Not seen exiting (an SSH restore before its terminal registers): listed pending under it.
expect(withMainSessionTabRows(published, projected, NONE_EXITED).tabs[0]).toMatchObject({
ptyId: 'pty-gone'
})
})
it('puts a window-only row of a group main lacks into the active group, not the first', () => {
const published: RuntimeMobileSessionTabsSnapshot = {
...windowSnapshot([terminal('t::a'), terminal('u::a'), browser('b1')], ['t'], 'u::a'),
activeGroupId: 'g2',
tabGroups: [
{ id: 'g', activeTabId: 't', tabOrder: ['t'] },
{ id: 'g2', activeTabId: 'u', tabOrder: ['u'] },
{ id: 'g3', activeTabId: 'b1', tabOrder: ['b1'] }
]
}
const projected: MainSessionTabRows = {
...mainProjection([terminal('t::a'), terminal('u::a')], ['t'], 't::a'),
tabGroups: [
{ id: 'g', activeTabId: 't', tabOrder: ['t'] },
{ id: 'g2', activeTabId: 'u', tabOrder: ['u'] }
]
}
const merged = withMainSessionTabRows(published, projected, NONE_EXITED)
expect(merged.tabGroups).toEqual([
{ id: 'g', activeTabId: 't', tabOrder: ['t'] },
{ id: 'g2', activeTabId: 'u', tabOrder: ['u', 'b1'] }
])
expect(merged).toMatchObject({ activeGroupId: 'g2', activeTabId: 'u::a' })
})
it("keeps each group's shown tab as main has it while main's selection stands", () => {
const merged = withMainSessionTabRows(
windowSnapshot([terminal('t::a'), terminal('t::new'), file('f1')], ['t', 'f1'], 't::new'),
mainProjection([terminal('t::a'), file('f1')], ['t', 'f1'], 'f1'),
NONE_EXITED
)
expect(merged).toMatchObject({ activeTabId: 'f1', activeTabType: 'file' })
expect(merged.tabGroups?.[0]?.activeTabId).toBe('f1')
expect(merged.tabs.filter((tab) => tab.isActive).map((tab) => tab.id)).toEqual(['f1'])
})
it("puts a stray row the window selects into main's active group", () => {
const published: RuntimeMobileSessionTabsSnapshot = {
...windowSnapshot([terminal('t::a'), terminal('u::a'), browser('b1')], ['t'], 'b1'),
activeGroupId: 'g3',
tabGroups: [
{ id: 'g', activeTabId: 't', tabOrder: ['t'] },
{ id: 'g2', activeTabId: 'u', tabOrder: ['u'] },
{ id: 'g3', activeTabId: 'b1', tabOrder: ['b1'] }
]
}
const projected: MainSessionTabRows = {
...mainProjection([terminal('t::a'), terminal('u::a')], ['t'], 'u::a'),
activeGroupId: 'g2',
tabGroups: [
{ id: 'g', activeTabId: 't', tabOrder: ['t'] },
{ id: 'g2', activeTabId: 'u', tabOrder: ['u'] }
]
}
const merged = withMainSessionTabRows(published, projected, NONE_EXITED)
expect(merged.tabGroups?.[1]).toEqual({ id: 'g2', activeTabId: 'b1', tabOrder: ['u', 'b1'] })
expect(merged).toMatchObject({ activeGroupId: 'g2', activeTabId: 'b1' })
})
it("takes a markdown edit view's dirty state from the window", () => {
const markdown = (isDirty: boolean) => ({
type: 'markdown' as const,
id: 'm1',
title: 'README.md',
filePath: '/wt/README.md',
relativePath: 'README.md',
language: 'markdown' as const,
mode: 'edit' as const,
sourceFileId: '/wt/README.md',
sourceFilePath: '/wt/README.md',
sourceRelativePath: 'README.md',
documentVersion: 'file:/wt/README.md',
isDirty,
isActive: false
})
const merged = withMainSessionTabRows(
windowSnapshot([markdown(true)], ['m1'], 'm1'),
mainProjection([markdown(false)], ['m1'], 'm1'),
NONE_EXITED
)
expect(merged.tabs[0]).toMatchObject({ id: 'm1', isDirty: true })
})
})
@@ -0,0 +1,227 @@
// Layout-owner builds, before main holds browser and chat records (D3b): a workspace main owns
// publishes its terminal and editor rows, tab order and groups from main's layout
// (projectSessionTabs). The window's live publication supplies what the user sees: selection, the
// focused pane, dirty state and live terminal titles (main's copy of the view lags a debounced
// save), the rows main's model does not hold (browser, chat, diff and other view-only editor rows)
// and the facts only the window has (terminal theme, agent status, launch drafts, a markdown
// view's mode, file id and draft version).
import type { RuntimeMobileSessionSnapshotTab } from '../../../shared/runtime-mobile-session-tab-contracts'
import type {
RuntimeMobileSessionTabGroup,
RuntimeMobileSessionTabsSnapshot
} from '../../../shared/runtime-session-contracts'
type Row = RuntimeMobileSessionSnapshotTab
/** Main's projection of the rows (projectSessionTabs): the snapshot minus the runtime's stamps. */
export type MainSessionTabRows = Omit<
RuntimeMobileSessionTabsSnapshot,
'publicationEpoch' | 'snapshotVersion' | 'worktreeInstanceId' | 'retiredTerminalSurfaces'
>
/**
* Rows main's layout answers for: terminals and edit-mode files (the model holds no diff or
* preview-only tab). Any other row is the window's until D3b.
*/
function isMainRow(row: Row): boolean {
return (
row.type === 'terminal' ||
(row.type === 'file' && row.mode !== 'diff') ||
(row.type === 'markdown' && row.mode === 'edit')
)
}
/**
* Main's row with the fields only the window knows, from the window's row of the same id. A
* window terminal id names a pane main has not bound yet (an SSH restore before its terminal
* registers), unless main has seen that terminal exit: then main's cleared binding wins.
*/
function withWindowFacts(
row: Row,
windowRow: Row | undefined,
exited: (ptyId: string) => boolean
): Row {
if (row.type === 'terminal' && windowRow?.type === 'terminal') {
return {
...row,
// The live title (shell or agent) unless either side has a manual name.
...(!row.customTitle && !windowRow.customTitle ? { title: windowRow.title } : {}),
...(windowRow.terminalTheme ? { terminalTheme: windowRow.terminalTheme } : {}),
...(windowRow.agentStatus !== undefined ? { agentStatus: windowRow.agentStatus } : {}),
...(!row.ptyId && windowRow.ptyId && !exited(windowRow.ptyId)
? { ptyId: windowRow.ptyId }
: {}),
...(windowRow.launchDraft !== undefined ? { launchDraft: windowRow.launchDraft } : {}),
...(windowRow.launchDraftCreatedAt !== undefined
? { launchDraftCreatedAt: windowRow.launchDraftCreatedAt }
: {})
}
}
if (row.type === 'markdown' && windowRow?.type === 'markdown') {
return {
...row,
isDirty: windowRow.isDirty,
mode: windowRow.mode,
sourceFileId: windowRow.sourceFileId,
documentVersion: windowRow.documentVersion
}
}
if (row.type === 'file' && windowRow?.type === 'file') {
return { ...row, isDirty: windowRow.isDirty }
}
return row
}
/** The id a group's tab order lists a row under: a terminal pane's tab, else the row itself. */
function orderIdOf(row: Row): string {
return row.type === 'terminal' ? row.parentTabId : row.id
}
/** Each window-only id placed after the id it follows in the window's group, else at the end. */
function withWindowOnlyIds(
tabOrder: readonly string[],
windowOrder: readonly string[],
windowOnly: ReadonlySet<string>
): string[] {
const order = [...tabOrder]
windowOrder.forEach((id, index) => {
if (!windowOnly.has(id) || order.includes(id)) {
return
}
const after = windowOrder
.slice(0, index)
.toReversed()
.find((previous) => order.includes(previous))
order.splice(after === undefined ? 0 : order.indexOf(after) + 1, 0, id)
})
return order
}
/** Main's rows in main's order, each window-only row after the row it follows in the window's. */
function withWindowOnlyRows(
rows: Row[],
windowRows: readonly Row[],
windowOnly: ReadonlySet<string>
): Row[] {
const merged = [...rows]
windowRows.forEach((row, index) => {
if (!windowOnly.has(row.id)) {
return
}
const after = windowRows
.slice(0, index)
.toReversed()
.find((previous) => merged.some((entry) => entry.id === previous.id))
const at = after ? merged.findIndex((entry) => entry.id === after.id) + 1 : 0
merged.splice(at, 0, row)
})
return merged
}
/**
* The window's live snapshot of a workspace main owns, with main's terminal and editor rows, tab
* order and groups. The window keeps its publication stamps (so the snapshot fences hold as today)
* and its view: what it selects stands whenever main lists that row.
*/
export function withMainSessionTabRows(
published: RuntimeMobileSessionTabsSnapshot,
projected: MainSessionTabRows,
exited: (ptyId: string) => boolean
): RuntimeMobileSessionTabsSnapshot {
const windowRows = new Map(published.tabs.map((row) => [row.id, row]))
const projectedIds = new Set(projected.tabs.map((row) => row.id))
const windowOnly = published.tabs.filter((row) => !projectedIds.has(row.id) && !isMainRow(row))
// A main-held row is main's; a browser or chat row stays the window's while both list it.
const rows = projected.tabs.map((row) =>
isMainRow(row)
? withWindowFacts(row, windowRows.get(row.id), exited)
: (windowRows.get(row.id) ?? row)
)
const windowOnlyIds = new Set(windowOnly.map((row) => row.id))
const merged = withWindowOnlyRows(rows, published.tabs, windowOnlyIds)
const listed = new Map(merged.map((row) => [row.id, row]))
const windowActive = published.activeTabId ? listed.get(published.activeTabId) : undefined
const windowGroups = new Map((published.tabGroups ?? []).map((group) => [group.id, group]))
const tabGroups: RuntimeMobileSessionTabGroup[] = (projected.tabGroups ?? []).map((group) => {
const tabOrder = withWindowOnlyIds(
group.tabOrder,
windowGroups.get(group.id)?.tabOrder ?? [],
windowOnlyIds
)
// The tab the window shows in this group, when main's group still holds it; while main's
// selection stands (the window selects a row main lacks), main's groups stay as main has them.
const shown = windowActive ? windowGroups.get(group.id)?.activeTabId : undefined
return {
...group,
tabOrder,
...(shown && tabOrder.includes(shown) ? { activeTabId: shown } : {})
}
})
const activeGroupIndex = (activeRow: Row | undefined): number => {
const byRow = activeRow
? tabGroups.findIndex((group) => group.tabOrder.includes(orderIdOf(activeRow)))
: -1
if (byRow !== -1) {
return byRow
}
const byWindow = windowActive
? tabGroups.findIndex((group) => group.id === published.activeGroupId)
: -1
return byWindow !== -1
? byWindow
: tabGroups.findIndex((group) => group.id === projected.activeGroupId)
}
const placed = new Set(tabGroups.flatMap((group) => group.tabOrder))
// A window-only row in a group main does not hold joins the active group: groups and their
// layout are main's, so the phone gets no group the layout lacks.
const stray = (published.tabGroups ?? []).flatMap((group) =>
group.tabOrder.filter((id) => windowOnlyIds.has(id) && !placed.has(id))
)
const strayInto = Math.max(activeGroupIndex(windowActive), 0)
const strayGroup = tabGroups[strayInto]
if (stray.length > 0 && strayGroup) {
tabGroups[strayInto] = { ...strayGroup, tabOrder: [...strayGroup.tabOrder, ...stray] }
} else if (stray.length > 0) {
// Main lists no group: the window's groups hold the rows only it knows.
for (const group of published.tabGroups ?? []) {
const own = group.tabOrder.filter((id) => windowOnlyIds.has(id))
if (own.length > 0) {
tabGroups.push({ ...group, tabOrder: own })
}
}
}
// The window's selection is the user's view; main's stands only while the window selects a row
// main does not list (a pane drawn before main's frame, or one main closed).
const index = activeGroupIndex(windowActive)
const activeGroup = index === -1 ? undefined : tabGroups[index]
if (windowActive && activeGroup) {
tabGroups[index] = { ...activeGroup, activeTabId: orderIdOf(windowActive) }
}
const selection = windowActive
? {
activeGroupId: activeGroup?.id ?? projected.activeGroupId,
activeTabId: windowActive.id,
activeTabType: windowActive.type
}
: {
activeGroupId: projected.activeGroupId,
activeTabId: projected.activeTabId,
activeTabType: projected.activeTabType
}
const activeId = selection.activeTabId
const { tabGroups: _windowGroups, tabGroupLayout: _windowLayout, ...stamps } = published
return {
...stamps,
...selection,
...(tabGroups.length > 0 ? { tabGroups } : {}),
...(projected.tabGroupLayout
? { tabGroupLayout: projected.tabGroupLayout }
: tabGroups.length > 0 && published.tabGroupLayout
? { tabGroupLayout: published.tabGroupLayout }
: {}),
tabs: merged.map((row) =>
row.isActive === (row.id === activeId) ? row : { ...row, isActive: row.id === activeId }
)
}
}
@@ -0,0 +1,44 @@
// A windowless server (orca serve, orcad): a change main makes to its own layout reaches a
// subscribed phone, with no window to republish rows. Rows there are rebuilt from the saved layout
// main writes (mobile-session-hydration.ts), not by A2's merge, which needs a window publication.
import { describe, expect, it, vi } from 'vitest'
import { HEADLESS_RUNTIME_WINDOW_ID } from '../../../shared/runtime-types'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts'
import { TEST_WORKTREE_ID } from '../orca-runtime-test-fixtures.spec'
import {
SURFACE_TAB_ID,
surfaceRuntime
} from '../terminal/surface/terminal-surface-replies.test-fixture'
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
describe('session.tabs on a windowless server (switch on)', () => {
it("a tab main renames reaches a subscribed phone's rows", async () => {
vi.useFakeTimers()
try {
const { runtime } = surfaceRuntime()
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
const seen: RuntimeMobileSessionTabsResult[] = []
runtime.onMobileSessionTabsChanged((snapshot) => {
seen.push(snapshot)
})
await runtime.applyLayoutCommand({
type: 'renameTab',
workspace: TEST_WORKTREE_ID,
tabId: SURFACE_TAB_ID,
kind: 'custom',
title: 'Build'
})
await vi.runAllTimersAsync()
// Pushed by main on its own, before any phone asks again.
expect(seen.at(-1)?.tabs.map((tab) => tab.title)).toEqual(['Build', 'Build'])
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(listed.tabs.map((tab) => tab.title)).toEqual(['Build', 'Build'])
} finally {
vi.useRealTimers()
}
})
})
@@ -35,6 +35,8 @@ export abstract class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees e
reads: () => this.workspaceLayoutReads,
homeHostId: (key) => this.tryGetWorkspaceSessionHostIdForWorktree(key),
registered: (ptyId) => this.ptysById.has(ptyId),
// Certified exit only: a dropped relay leaves records disconnected but unverifiable.
exited: (ptyId) => this.ptyLivenessVerdictByPtyId.get(ptyId)?.verdict.status === 'exited',
paneStartFact: (paneKey, binding) => this.paneStartStatuses?.get(paneKey, binding) ?? null,
readyGraphPublisher: () => (this.graphStatus === 'ready' ? this.authoritativeWindowId : null),
publish: (publisher, next) => {
@@ -0,0 +1,153 @@
// Phone-mirror parity (flip step 7) against main's projection: with the switch on, the rows a
// phone or paired desktop reads for a workspace main owns follow main's layout after a create, a
// close and a rename, whatever rows the window's next publication still carries. Main's rows land
// with that publication (the window republishes after each layout frame), not before it.
import { describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-session-contracts'
import { HEADLESS_RUNTIME_WINDOW_ID } from '../../../shared/runtime-types'
import {
HEADLESS_LEAF_ID,
HEADLESS_SECOND_LEAF_ID,
HEADLESS_THIRD_LEAF_ID,
TEST_WORKTREE_ID
} from '../orca-runtime-test-fixtures.spec'
import {
SURFACE_TAB_ID,
surfaceRuntime
} from '../terminal/surface/terminal-surface-replies.test-fixture'
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
const row = (leafId: string) => `${SURFACE_TAB_ID}::${leafId}`
/** The window's publication as it was before main's change: both panes, the old title. */
function stalePublication(
version: number,
activeLeafId: string = HEADLESS_LEAF_ID
): RuntimeMobileSessionTabsSnapshot {
const tabs = [HEADLESS_LEAF_ID, HEADLESS_SECOND_LEAF_ID].map((leafId) => ({
type: 'terminal' as const,
id: row(leafId),
title: 'Persisted Terminal',
parentTabId: SURFACE_TAB_ID,
leafId,
isActive: leafId === activeLeafId
}))
return {
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'renderer',
snapshotVersion: version,
activeGroupId: null,
activeTabId: row(activeLeafId),
activeTabType: 'terminal',
tabs
}
}
async function phoneActiveRow(runtime: ReturnType<typeof surfaceRuntime>['runtime']) {
return (await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).activeTabId
}
async function phoneRows(runtime: ReturnType<typeof surfaceRuntime>['runtime']) {
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
return listed.tabs.map((tab) => ({
id: tab.id,
title: tab.title,
customTitle: tab.type === 'terminal' ? tab.customTitle : undefined
}))
}
describe('session.tabs rows follow main’s layout (switch on)', () => {
it('a pane main split in, and a pane main closed, before the window republished', async () => {
const { runtime, store, setSession } = surfaceRuntime()
runtime.attachWindow(1)
const session = store.getSession()
const layout = session.terminalLayoutsByTabId[SURFACE_TAB_ID]!
setSession({
...session,
terminalLayoutsByTabId: {
[SURFACE_TAB_ID]: {
...layout,
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: HEADLESS_LEAF_ID },
second: { type: 'leaf', leafId: HEADLESS_THIRD_LEAF_ID }
}
}
}
})
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [stalePublication(1)] })
const rows = await phoneRows(runtime)
expect(rows.map((entry) => entry.id)).toEqual([
row(HEADLESS_LEAF_ID),
row(HEADLESS_THIRD_LEAF_ID)
])
})
it('a tab main renamed before the window republished', async () => {
const { runtime } = surfaceRuntime()
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [stalePublication(1)] })
await runtime.applyLayoutCommand({
type: 'renameTab',
workspace: TEST_WORKTREE_ID,
tabId: SURFACE_TAB_ID,
kind: 'custom',
title: 'Build'
})
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [stalePublication(2)] })
const rows = await phoneRows(runtime)
expect(rows.map((entry) => [entry.title, entry.customTitle])).toEqual([
['Build', 'Build'],
['Build', 'Build']
])
})
it("save or view lands after publication: the phone's row updates", async () => {
const { runtime, store, setSession } = surfaceRuntime()
runtime.attachWindow(1)
// The user focuses the second pane: the window publishes at once, its save lands later.
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: [stalePublication(1, HEADLESS_SECOND_LEAF_ID)]
})
expect(await phoneActiveRow(runtime)).toBe(row(HEADLESS_SECOND_LEAF_ID))
const session = store.getSession()
const layout = session.terminalLayoutsByTabId[SURFACE_TAB_ID]!
setSession({
...session,
terminalLayoutsByTabId: {
[SURFACE_TAB_ID]: { ...layout, activeLeafId: HEADLESS_SECOND_LEAF_ID }
}
})
expect(await phoneActiveRow(runtime)).toBe(row(HEADLESS_SECOND_LEAF_ID))
// Back to the first pane, published before its save: the phone follows the view.
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: [stalePublication(2, HEADLESS_LEAF_ID)]
})
expect(await phoneActiveRow(runtime)).toBe(row(HEADLESS_LEAF_ID))
})
it("with no window, rows follow main's saved view", async () => {
const { runtime, store, setSession } = surfaceRuntime()
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
const session = store.getSession()
const layout = session.terminalLayoutsByTabId[SURFACE_TAB_ID]!
setSession({
...session,
terminalLayoutsByTabId: {
[SURFACE_TAB_ID]: { ...layout, activeLeafId: HEADLESS_SECOND_LEAF_ID }
}
})
expect(await phoneActiveRow(runtime)).toBe(row(HEADLESS_SECOND_LEAF_ID))
})
})
@@ -7,6 +7,8 @@ import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id
import type { MainPaneBinding } from './main-pane-verdict'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { RuntimeSyncWindowGraph } from '../../../../shared/runtime-types'
import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-session-contracts'
import { withMainSessionTabRows } from '../../session-tabs/main-session-tab-rows'
import type { RuntimeStore } from '../../runtime-store-contract'
import type { WorkspaceLayoutReads } from '../../workspace-layout-stream'
import {
@@ -22,6 +24,8 @@ export type MainTerminalSurfacesHost = {
homeHostId: (key: string) => ExecutionHostId | null
/** Whether the PTY registry holds the terminal. */
registered: (ptyId: string) => boolean
/** Whether the host certified the terminal exited (not merely disconnected or unverifiable). */
exited: (ptyId: string) => boolean
/** Main's start status of a pane, as the layout stream publishes it. */
paneStartFact: PaneStartFactOf
/** The graph authority now, when its graph is ready; null otherwise. */
@@ -54,7 +58,23 @@ class MainSurfacePublisher {
this.index.start()
this.lastWindowGraph = { windowId, graph }
this.installed = this.index.surfaces()
return composeMainTerminalSurfaces(graph, this.installed, this.host.registered)
const composed = composeMainTerminalSurfaces(graph, this.installed, this.host.registered)
return graph.mobileSessionTabs
? {
...composed,
mobileSessionTabs: graph.mobileSessionTabs.map((rows) => this.withMainRows(rows))
}
: composed
}
/** A workspace main owns publishes main's terminal and editor rows; any other, the window's. */
private withMainRows(
published: RuntimeMobileSessionTabsSnapshot
): RuntimeMobileSessionTabsSnapshot {
const projected = this.installed?.worktreeIds.has(published.worktree)
? this.index.sessionTabsOf(published.worktree)
: null
return projected ? withMainSessionTabRows(published, projected, this.host.exited) : published
}
/** The pane main's layout binds a terminal to, read fresh; null when no pane main owns holds it. */
@@ -11,6 +11,10 @@ import type {
import type { LoadedWorkspaceLayout } from '../../../../shared/workspace-layout/workspace-layout-load-types'
import { collectLayoutLeafIdsInOrder } from '../../../../shared/workspace-layout/terminal-pane-tree'
import { bindingOf } from '../../pane-start-statuses'
import {
projectSessionTabs,
type ProjectedSessionTabs
} from '../../../../shared/workspace-layout/old-client/old-client-session-tabs-projection'
import { projectRuntimeSurfaces } from '../../runtime-leaf-record-projection'
import type { WorkspaceLayoutReads } from '../../workspace-layout-stream'
@@ -175,6 +179,29 @@ export class TerminalSurfaceIndex {
}
}
/** A workspace main owns as today's `session.tabs` rows, from its owning partition's layout. */
sessionTabsOf(worktreeId: string): ProjectedSessionTabs | null {
this.surfaces()
const hostIds = this.deps
.hostIds()
.filter((hostId) => parseExecutionHostId(hostId)?.kind !== 'runtime')
for (const [index, partition] of this.projectedFrom.entries()) {
const hostId = hostIds[index]
for (const [key, workspace] of Object.entries(partition?.layout.workspaces ?? {})) {
if (partition && workspace.worktreeId === worktreeId && this.routes.get(key) === hostId) {
return projectSessionTabs(
key,
workspace,
partition.desktopView,
partition.facts,
this.deps.generatedTitlesEnabled()
)
}
}
}
return null
}
private homeOf(key: string): ExecutionHostId | null {
try {
return this.deps.homeHostId(key)
@@ -0,0 +1,18 @@
import type { WorkspaceLayout } from '../workspace-layout-model'
import type { TabGroupLayoutNode } from '../../tab-types'
/** A workspace's groups in its group layout's order; groups the layout misses go last. */
export function groupsInLayoutOrder(workspace: WorkspaceLayout): WorkspaceLayout['groups'] {
const ids: string[] = []
const visit = (node: TabGroupLayoutNode | undefined): void => {
if (node?.type === 'leaf') {
ids.push(node.groupId)
} else if (node) {
visit(node.first)
visit(node.second)
}
}
visit(workspace.groupLayout)
const placed = ids.flatMap((id) => workspace.groups.filter((group) => group.id === id))
return [...placed, ...workspace.groups.filter((group) => !placed.includes(group))]
}
@@ -175,6 +175,7 @@ describe('projectSessionTabs', () => {
type: 'terminal',
id: `${apiTab}::${api!.leafId!}`,
title: 'logs',
customTitle: 'api server',
parentTabId: apiTab,
leafId: api!.leafId!,
ptyId: 'pty-1',
@@ -188,6 +189,7 @@ describe('projectSessionTabs', () => {
type: 'terminal',
id: `${apiTab}::${apiSplit}`,
title: 'Terminal',
customTitle: 'api server',
parentTabId: apiTab,
leafId: apiSplit,
ptyId: 'pty-2',
@@ -200,6 +202,7 @@ describe('projectSessionTabs', () => {
type: 'terminal',
id: `${agent!.tabId!}::${agent!.leafId!}`,
title: 'Claude Code',
customTitle: null,
parentTabId: agent!.tabId!,
leafId: agent!.leafId!,
launchAgent: 'claude',
@@ -18,7 +18,6 @@ import type {
RuntimeMobileSessionTabGroup,
RuntimeMobileSessionTabsSnapshot
} from '../../runtime-session-contracts'
import type { TabGroupLayoutNode } from '../../tab-types'
import type { TerminalLayoutSnapshot } from '../../terminal-tab-types'
import { pruneGroupLayout } from '../../workspace-session-terminal-tab-close'
import {
@@ -27,6 +26,7 @@ import {
terminalRowId
} from './old-client-session-tab-rows'
import { collectLayoutLeafIdsInOrder } from '../terminal-pane-tree'
import { groupsInLayoutOrder } from './old-client-group-order'
import {
BLANK_BROWSER_TAB_STATE,
type DesktopLayoutView,
@@ -143,21 +143,6 @@ function resolveActiveGroupId(
return groupsInLayoutOrder(workspace)[0]?.id ?? null
}
function groupsInLayoutOrder(workspace: WorkspaceLayout): WorkspaceLayout['groups'] {
const ids: string[] = []
const visit = (node: TabGroupLayoutNode | undefined): void => {
if (node?.type === 'leaf') {
ids.push(node.groupId)
} else if (node) {
visit(node.first)
visit(node.second)
}
}
visit(workspace.groupLayout)
const placed = ids.flatMap((id) => workspace.groups.filter((group) => group.id === id))
return [...placed, ...workspace.groups.filter((group) => !placed.includes(group))]
}
function projectTab(tab: LayoutTab, scope: Scope): RuntimeMobileSessionSnapshotTab[] {
switch (tab.kind) {
case 'terminal':
@@ -225,6 +210,8 @@ function terminalRows(tab: LayoutTerminalTab, scope: Scope): RuntimeMobileSessio
type: 'terminal',
id: terminalRowId(tab.entityId, leafId),
title,
// A paired desktop reads an absent name as "keep mine", so a cleared name is sent as null.
customTitle: tab.customTitle?.trim() || null,
...(tabWide && tab.quickCommandLabel?.trim()
? { quickCommandLabel: tab.quickCommandLabel.trim() }
: {}),