Phones get main's tab order, groups and names after every layout change (dark, layout-owner build only) (#31529)

* fix(session-tabs): a windowless layout-owner server lists and pushes main's tab groups and order

With the layout-owner switch on and no window (orca serve, orcad), session.tabs rows came from
the snapshot hydrated from the saved session; main's layout was merged in only when a window
published. A routed reorder or Move to Split reached main's layout but not session.tabs.list or
the phone. Main now merges its layout into the rows it holds on each layout change and before
each list, with the same merge a window publication gets.

* refactor(session-tabs): keep the windowless merge with main's publisher, under the file's line cap

* WIP

* fix(session-tabs): every stored tab row follows main's layout, with or without a window

With the layout-owner switch on, the one writer of the rows phones read now merges main's
layout into every write for a workspace main owns, and main re-merges the held rows after
each layout commit on any host. A CLI rename, create or close reaches phones without a
window publish, and a row main's layout dropped cannot come back from an older snapshot.
A write that main's layout wholly overrides stores nothing, so list calls on settled rows
bump no version and push nothing. A pane's own title now counts as a manual name.

This commit also untracks a local vitest config and a scratch test that the previous
work-in-progress commit picked up by mistake.

* test(session-tabs): a rebuild from the saved session and a CLI rename keep main's groups and names on phones

* fix(session-tabs): the row writer only reorders and regroups; layout commits add main's new rows

With the layout-owner switch on, the one writer of the rows phones read now takes only main's
tab order and groups. It never adds or drops a row and never replaces a terminal id, so a
freshly started terminal keeps its id and rows the exit filters removed stay removed.

After each layout commit, on any host, the held rows take main's new rows, names and order,
keep any row main's layout lacks (closes are the close path's job), and pass any added
terminal through the same exit filters the window path uses. A main binding that runs nowhere
no longer replaces a running terminal's id. Both read main's last projection, so neither
adds a session read per write or per workspace.

A CLI close still leaves its row until the dropped-leaf hook lands; its test is marked as an
expected failure until then.

* test(session-tabs): the row writer's order and a commit merge's keep rules, unit by unit

* fix(session-tabs): a kept row keeps its group, and an unchanged commit pushes nothing

A layout commit's merge placed a row it kept (a tab main closed, until the dropped-leaf hook
removes it) by its row id, but groups list a terminal by its tab, so the row fell into the
active group. Both the writer's reorder and the merge now place rows through one shared
group-placement step keyed by the tab.

A commit now pushes only when the writer stored something. The writer skips a write only
when it keeps the stored occupant, publication and retirement proofs. A terminal row a commit
rebinds also passes the exit filters, a main binding the host saw exit no longer replaces a
running terminal, and a binding main made before its terminal connected reaches the rows when
it connects.

* test(session-tabs): a kept row's place and the no-push commit are separate tests

* fix(session-tabs): a rebuild with a new publication stamp is still skipped; simpler commit merge

The writer's skip compared publication stamps, but a rebuild from the saved session mints a
new stamp from the clock each time, so every rebuild minted a version and pushed. It now
stores only for a different workspace instance or new retirement proofs; an absent one keeps
what is stored.

The commit merge again runs the exit filters only for added terminal rows (they admit any row
the held rows list, so a rebind changed nothing), and a terminal connecting now schedules the
merge itself rather than a separate check, since an unchanged merge stores and pushes nothing.

* refactor(session-tabs): a commit merge pushes after every store again; name the window-path exit test

No reachable case was found where a commit merge's rows differ from the held rows while the
writer keeps the stored ones, so the extra "only when stored" check had nothing to guard and
goes. The exit-rule test now says it covers the window's publication path too.

* fix(session-tabs): a window reload's new publication stamp is stored, not skipped

The writer skipped a write that main's order wholly undid without comparing publication stamps,
so a window reload that republished the same rows under a new stamp kept the old one. The
reconciler then saw its accepted stamp missing and asked the window to republish on every sync,
and a host-built stamp could stay on window rows. The skip now treats stamps as unchanged only
when both are host-built, so repeated rebuilds from the saved session still mint nothing.

The windowed test of a row main's layout lacks also replays the window's older snapshot.
This commit is contained in:
Jinwoo Hong
2026-10-21 10:53:40 -04:00
committed by GitHub
parent 6970cad0d3
commit de7a2d24f9
17 changed files with 1296 additions and 76 deletions
@@ -1,5 +1,11 @@
import { randomUUID } from 'node:crypto'
import { preserveTerminalRetirementProofs } from '../mobile-session-terminal-retirement-proof'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { startedMainTerminalSurfaces } from '../terminal/surface/main-terminal-surfaces'
import {
headlessMobileSnapshotContentUnchanged,
mobileSnapshotIdentityUnchanged
} from '../mobile-session-snapshot-equality'
import type { TuiAgent } from '../../../shared/tui-agent'
import { isTuiAgent } from '../../../shared/tui-agent-config'
import type { RuntimeStore } from '../runtime-store-contract'
@@ -111,6 +117,21 @@ export abstract class OrcaRuntimeWithRuntimeId extends RuntimeConstructionServic
snapshot: RuntimeMobileSessionTabsSnapshot
): RuntimeMobileSessionTabsSnapshot {
const existing = this.mobileSessionTabsByWorktree.get(worktreeId)
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// Every write keeps main's order and groups; its rows and their facts stay as written.
const merged = startedMainTerminalSurfaces(this)?.withMainOrder(snapshot) ?? snapshot
// A write main's order wholly overrides (a rebuild from the saved session) changes nothing,
// unless it names a new occupant, window publication or retirement proof.
if (
existing &&
mobileSnapshotIdentityUnchanged(existing, snapshot) &&
headlessMobileSnapshotContentUnchanged(existing, merged) &&
!headlessMobileSnapshotContentUnchanged(snapshot, merged)
) {
return existing
}
snapshot = merged
}
snapshot = preserveTerminalRetirementProofs(snapshot, existing)
const snapshotVersion = existing
? Math.max(snapshot.snapshotVersion, existing.snapshotVersion + 1)
@@ -59,3 +59,28 @@ export function mobileSnapshotValueEqual(a: unknown, b: unknown): boolean {
}
return false
}
/** Whether rows were built by the host itself (not a window's publication, merges included). */
export function isHeadlessBuiltPublication(publicationEpoch: string): boolean {
const base = publicationEpoch.split(':headless-merge:')[0] ?? publicationEpoch
return base.startsWith('headless:') || base.startsWith('headless-hydrated:')
}
/**
* Whether a write keeps the stored occupant, publication and retirement proofs; an absent occupant
* or proof keeps them. Two host-built stamps count as one: each rebuild mints its own.
*/
export function mobileSnapshotIdentityUnchanged(
existing: RuntimeMobileSessionTabsSnapshot,
next: RuntimeMobileSessionTabsSnapshot
): boolean {
return (
(next.worktreeInstanceId === undefined ||
next.worktreeInstanceId === existing.worktreeInstanceId) &&
(existing.publicationEpoch === next.publicationEpoch ||
(isHeadlessBuiltPublication(existing.publicationEpoch) &&
isHeadlessBuiltPublication(next.publicationEpoch))) &&
(next.retiredTerminalSurfaces === undefined ||
mobileSnapshotValueEqual(next.retiredTerminalSurfaces, existing.retiredTerminalSurfaces))
)
}
@@ -0,0 +1,105 @@
// The rules a layout commit's merge adds to A2's (keep rows main lacks, and a running terminal),
// and the exit rule it shares with the window's publication path.
import { describe, expect, it } from 'vitest'
import type { 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, ptyId?: string): RuntimeMobileSessionTerminalTab {
const [parentTabId = id, leafId = id] = id.split('::')
return {
type: 'terminal',
id,
title: 'Terminal',
parentTabId,
leafId,
isActive: false,
...(ptyId ? { ptyId } : {})
}
}
function rows(tabs: RuntimeMobileSessionTerminalTab[]): MainSessionTabRows {
return {
worktree: WT,
activeGroupId: 'g',
activeTabId: tabs[0]?.id ?? null,
activeTabType: 'terminal',
tabGroups: [
{ id: 'g', activeTabId: 't', tabOrder: [...new Set(tabs.map((t) => t.parentTabId))] }
],
tabs
}
}
const held = (tabs: RuntimeMobileSessionTerminalTab[]): RuntimeMobileSessionTabsSnapshot => ({
...rows(tabs),
publicationEpoch: 'renderer',
snapshotVersion: 4
})
describe('a layout commit merged into held rows (layout-owner build)', () => {
it("keeps a terminal row main's layout lacks only when asked to", () => {
const published = held([terminal('t::1'), terminal('u::1')])
const projected = rows([terminal('t::1')])
expect(withMainSessionTabRows(published, projected, NONE_EXITED).tabs.map((t) => t.id)).toEqual(
['t::1']
)
expect(
withMainSessionTabRows(published, projected, NONE_EXITED, { keepUnlisted: true }).tabs.map(
(t) => t.id
)
).toEqual(['t::1', 'u::1'])
})
it('keeps a running terminal over a main binding that runs nowhere, given liveness', () => {
const published = held([terminal('t::1', 'pty-new')])
const projected = rows([terminal('t::1', 'pty-old')])
const live = (ptyId: string): boolean => ptyId === 'pty-new'
expect(withMainSessionTabRows(published, projected, NONE_EXITED).tabs[0]).toMatchObject({
ptyId: 'pty-old'
})
expect(
withMainSessionTabRows(published, projected, NONE_EXITED, { live }).tabs[0]
).toMatchObject({ ptyId: 'pty-new' })
// Both running: main's binding is the newer word.
expect(
withMainSessionTabRows(published, projected, NONE_EXITED, { live: () => true }).tabs[0]
).toMatchObject({ ptyId: 'pty-old' })
})
it("window publication: the window's live terminal stands when main binds one the host saw exit", () => {
const published = held([terminal('t::1', 'pty-live')])
const projected = rows([terminal('t::1', 'pty-retired')])
expect(
withMainSessionTabRows(published, projected, (ptyId) => ptyId === 'pty-retired').tabs[0]
).toMatchObject({ ptyId: 'pty-live' })
})
it("keeps a kept pane's tab where it stood in its group, not in the active group", () => {
const published: RuntimeMobileSessionTabsSnapshot = {
...held([terminal('t::1'), terminal('v::1'), terminal('u::1')]),
activeGroupId: 'g1',
tabGroups: [
{ id: 'g1', activeTabId: 't', tabOrder: ['t'] },
{ id: 'g2', activeTabId: 'v', tabOrder: ['v', 'u'] }
]
}
const projected: MainSessionTabRows = {
...rows([terminal('t::1'), terminal('v::1')]),
tabGroups: [
{ id: 'g1', activeTabId: 't', tabOrder: ['t'] },
{ id: 'g2', activeTabId: 'v', tabOrder: ['v'] }
]
}
const merged = withMainSessionTabRows(published, projected, NONE_EXITED, { keepUnlisted: true })
expect(merged.tabGroups?.map((group) => group.tabOrder)).toEqual([['t'], ['v', 'u']])
})
})
@@ -0,0 +1,66 @@
// Layout-owner builds: where rows one side lists and main's layout does not go in main's groups.
// Both the row writer's reorder and A2's merge place them the same way.
import type { RuntimeMobileSessionSnapshotTab } from '../../../shared/runtime-mobile-session-tab-contracts'
import type { RuntimeMobileSessionTabGroup } from '../../../shared/runtime-session-contracts'
type Group = RuntimeMobileSessionTabGroup
/** The id a group's tab order lists a row under: a terminal pane's tab, else the row itself. */
export function orderIdOf(row: RuntimeMobileSessionSnapshotTab): string {
return row.type === 'terminal' ? row.parentTabId : row.id
}
/** Each side-only id placed after the id it follows in the side's group, else at the start. */
export 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 groups, each with the side-only order ids (orderIdOf) the side's group of the same id
* lists, where they stood there. Ids in a group main lacks, or in no group (in `sideOrder`), come
* back as `stray` for the caller's active group.
*/
export function placeInMainGroups(
mainGroups: readonly Group[],
sideGroups: readonly Group[],
sideOnly: ReadonlySet<string>,
sideOrder: readonly string[] = []
): { groups: Group[]; stray: string[] } {
const side = new Map(sideGroups.map((group) => [group.id, group]))
const groups = mainGroups.map((group) => ({
...group,
tabOrder: withWindowOnlyIds(group.tabOrder, side.get(group.id)?.tabOrder ?? [], sideOnly)
}))
const placed = new Set(groups.flatMap((group) => group.tabOrder))
const stray = [
...new Set([...sideGroups.flatMap((group) => group.tabOrder), ...sideOrder])
].filter((id) => sideOnly.has(id) && !placed.has(id))
return { groups, stray }
}
/** The groups with the stray ids appended to the one at `index`; unchanged when it is absent. */
export function withStrayIn(groups: Group[], stray: readonly string[], index: number): Group[] {
const target = groups[index]
if (!target || stray.length === 0) {
return groups
}
return groups.map((group, at) =>
at === index ? { ...target, tabOrder: [...target.tabOrder, ...stray] } : group
)
}
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import type { RuntimeMobileSessionTerminalTab } from '../../../shared/runtime-mobile-session-tab-contracts'
import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-session-contracts'
import type { MainSessionTabRows } from './main-session-tab-rows'
import { withMainTabOrder } from './main-session-tab-order'
const WT = 'repo::/wt'
function terminal(id: string, ptyId?: string): RuntimeMobileSessionTerminalTab {
const [parentTabId = id, leafId = id] = id.split('::')
return {
type: 'terminal',
id,
title: id,
parentTabId,
leafId,
isActive: false,
...(ptyId ? { ptyId } : {})
}
}
function written(tabs: RuntimeMobileSessionTerminalTab[]): RuntimeMobileSessionTabsSnapshot {
return {
worktree: WT,
publicationEpoch: 'headless-hydrated:1',
snapshotVersion: 3,
activeGroupId: 'headless',
activeTabId: tabs[0]?.id ?? null,
activeTabType: 'terminal',
tabGroups: [
{ id: 'headless', activeTabId: tabs[0]?.parentTabId ?? null, tabOrder: ['a', 'b', 'c'] }
],
tabs
}
}
/** Main's layout: `c` first in one group, `a` split off in a second; main lacks `b`, holds `d`. */
const MAIN: MainSessionTabRows = {
worktree: WT,
activeGroupId: 'left',
activeTabId: 'c::1',
activeTabType: 'terminal',
tabGroups: [
{ id: 'left', activeTabId: 'c', tabOrder: ['c', 'd'] },
{ id: 'right', activeTabId: 'a', tabOrder: ['a'] }
],
tabGroupLayout: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'left' },
second: { type: 'leaf', groupId: 'right' }
},
tabs: [terminal('c::1'), terminal('d::1'), terminal('a::1')]
}
describe("the row writer's order from main (layout-owner build)", () => {
it("orders and groups the written rows as main's layout does, adding and dropping none", () => {
const rows = [terminal('a::1', 'pty-new'), terminal('b::1'), terminal('c::1')]
const ordered = withMainTabOrder(written(rows), MAIN)
// `b`, in no group main holds, joins the written selection's group.
expect(ordered.tabs.map((tab) => tab.id)).toEqual(['c::1', 'a::1', 'b::1'])
expect(ordered.tabGroups?.map((group) => [group.id, group.tabOrder])).toEqual([
['left', ['c']],
['right', ['a', 'b']]
])
expect(ordered.tabGroupLayout).toEqual(MAIN.tabGroupLayout)
})
it("keeps every fact a written row carries, its terminal id and the writer's selection", () => {
const rows = [terminal('a::1', 'pty-new'), terminal('c::1')]
const ordered = withMainTabOrder(written(rows), MAIN)
expect(ordered.tabs.find((tab) => tab.id === 'a::1')).toEqual(rows[0])
expect(ordered).toMatchObject({ activeTabId: 'a::1', activeGroupId: 'right' })
})
it('leaves the rows as written when main lists no group', () => {
const write = written([terminal('a::1')])
expect(withMainTabOrder(write, { ...MAIN, tabGroups: undefined })).toBe(write)
})
})
@@ -0,0 +1,78 @@
// Layout-owner builds: what the one writer of session.tabs rows takes from main's layout for a
// workspace main owns. Only the tab order and the groups: which rows exist, and every fact a row
// carries (its terminal id above all), stay as written, since a writer may know a terminal or an
// exit main's layout has not caught up with.
import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-session-contracts'
import type { MainSessionTabRows } from './main-session-tab-rows'
import { orderIdOf, placeInMainGroups, withStrayIn } from './main-session-tab-group-placement'
/** The written rows, ordered and grouped as main's layout has them. */
export function withMainTabOrder(
written: RuntimeMobileSessionTabsSnapshot,
projected: MainSessionTabRows
): RuntimeMobileSessionTabsSnapshot {
const mainGroups = projected.tabGroups ?? []
if (mainGroups.length === 0 || written.tabs.length === 0) {
return written
}
const writtenOrder = [...new Set(written.tabs.map(orderIdOf))]
const writtenIds = new Set(writtenOrder)
const mainPlaced = new Set(mainGroups.flatMap((group) => group.tabOrder))
const writtenOnly = new Set(writtenOrder.filter((id) => !mainPlaced.has(id)))
const writtenGroups = new Map((written.tabGroups ?? []).map((group) => [group.id, group]))
const placement = placeInMainGroups(
mainGroups.map((group) => ({
...group,
tabOrder: group.tabOrder.filter((id) => writtenIds.has(id))
})),
written.tabGroups ?? [],
writtenOnly,
writtenOrder
)
const placedGroups = placement.groups.map((group) => {
const own = writtenGroups.get(group.id)
// The written view's selection in the group stands while the group still lists it.
const activeTabId =
[own?.activeTabId, group.activeTabId].find((id) => !!id && group.tabOrder.includes(id)) ??
group.tabOrder[0] ??
null
return { ...group, activeTabId }
})
const activeRow = written.tabs.find((row) => row.id === written.activeTabId)
const indexOf = (groupId: string | null): number =>
placedGroups.findIndex((group) => group.id === groupId)
const byRow = activeRow
? placedGroups.findIndex((group) => group.tabOrder.includes(orderIdOf(activeRow)))
: -1
const byWritten = indexOf(written.activeGroupId)
const activeIndex =
byRow !== -1
? byRow
: byWritten !== -1
? byWritten
: Math.max(indexOf(projected.activeGroupId), 0)
// A written row in a group main does not hold joins the active group: groups are main's.
const groups = withStrayIn(placedGroups, placement.stray, activeIndex)
const tabGroups = groups.filter((group) => group.tabOrder.length > 0)
const position = new Map(tabGroups.flatMap((group) => group.tabOrder).map((id, at) => [id, at]))
const tabs = written.tabs
.map((row, at) => ({ row, at }))
.toSorted(
(a, b) =>
(position.get(orderIdOf(a.row)) ?? position.size) -
(position.get(orderIdOf(b.row)) ?? position.size) || a.at - b.at
)
.map(({ row }) => row)
const activeGroupId = groups[activeIndex]?.id ?? null
const { tabGroupLayout: _writtenLayout, ...rest } = written
return {
...rest,
activeGroupId: tabGroups.some((group) => group.id === activeGroupId)
? activeGroupId
: (tabGroups[0]?.id ?? written.activeGroupId),
tabGroups,
...(projected.tabGroupLayout ? { tabGroupLayout: projected.tabGroupLayout } : {}),
tabs
}
}
@@ -7,6 +7,7 @@
// view's mode, file id and draft version).
import type { RuntimeMobileSessionSnapshotTab } from '../../../shared/runtime-mobile-session-tab-contracts'
import { orderIdOf, placeInMainGroups, withStrayIn } from './main-session-tab-group-placement'
import type {
RuntimeMobileSessionTabGroup,
RuntimeMobileSessionTabsSnapshot
@@ -32,26 +33,53 @@ function isMainRow(row: Row): boolean {
)
}
/** A terminal row with a manual name: its tab's custom title or its pane's own title. */
function isNamed(row: Extract<Row, { type: 'terminal' }>): boolean {
return !!row.customTitle || !!row.parentLayout?.titlesByLeafId?.[row.leafId]?.trim()
}
type MergeRules = {
exited: (ptyId: string) => boolean
/** Whether a terminal runs now; given, a running window terminal outranks a binding that runs nowhere. */
live?: (ptyId: string) => boolean
/** Keeps every window row main does not list, terminals too, where they stood in the window. */
keepUnlisted?: boolean
}
/** Whether the window's terminal id stands over main's binding (see withWindowFacts). */
function keepsWindowPtyId(
main: string | null | undefined,
window: string | null | undefined,
{ exited, live }: MergeRules
): boolean {
if (!window || window === main) {
return false
}
if (!main) {
return !exited(window)
}
// Main's binding names a terminal the host saw exit; the window's has not.
if (exited(main)) {
return !exited(window)
}
// A start whose bind write is still in flight: main's binding is the terminal it replaced.
return !!live && live(window) && !live(main)
}
/**
* 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 {
function withWindowFacts(row: Row, windowRow: Row | undefined, rules: MergeRules): 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 } : {}),
...(!isNamed(row) && !isNamed(windowRow) ? { title: windowRow.title } : {}),
...(windowRow.terminalTheme ? { terminalTheme: windowRow.terminalTheme } : {}),
...(windowRow.agentStatus !== undefined ? { agentStatus: windowRow.agentStatus } : {}),
...(!row.ptyId && windowRow.ptyId && !exited(windowRow.ptyId)
? { ptyId: windowRow.ptyId }
: {}),
...(keepsWindowPtyId(row.ptyId, windowRow.ptyId, rules) ? { ptyId: windowRow.ptyId } : {}),
...(windowRow.launchDraft !== undefined ? { launchDraft: windowRow.launchDraft } : {}),
...(windowRow.launchDraftCreatedAt !== undefined
? { launchDraftCreatedAt: windowRow.launchDraftCreatedAt }
@@ -73,31 +101,6 @@ function withWindowFacts(
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[],
@@ -127,15 +130,19 @@ function withWindowOnlyRows(
export function withMainSessionTabRows(
published: RuntimeMobileSessionTabsSnapshot,
projected: MainSessionTabRows,
exited: (ptyId: string) => boolean
exited: (ptyId: string) => boolean,
opts: Omit<MergeRules, 'exited'> = {}
): RuntimeMobileSessionTabsSnapshot {
const rules = { ...opts, exited }
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))
const windowOnly = published.tabs.filter(
(row) => !projectedIds.has(row.id) && (rules.keepUnlisted || !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)
? withWindowFacts(row, windowRows.get(row.id), rules)
: (windowRows.get(row.id) ?? row)
)
const windowOnlyIds = new Set(windowOnly.map((row) => row.id))
@@ -143,20 +150,18 @@ export function withMainSessionTabRows(
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.
// Groups list a terminal under its tab (orderIdOf), so a kept pane keeps its tab's place.
const windowOnlyOrder = new Set(windowOnly.map(orderIdOf))
const placement = placeInMainGroups(
projected.tabGroups ?? [],
published.tabGroups ?? [],
windowOnlyOrder
)
// The tab the window shows in each 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.
let tabGroups: RuntimeMobileSessionTabGroup[] = placement.groups.map((group) => {
const shown = windowActive ? windowGroups.get(group.id)?.activeTabId : undefined
return {
...group,
tabOrder,
...(shown && tabOrder.includes(shown) ? { activeTabId: shown } : {})
}
return shown && group.tabOrder.includes(shown) ? { ...group, activeTabId: shown } : group
})
const activeGroupIndex = (activeRow: Row | undefined): number => {
const byRow = activeRow
@@ -172,20 +177,15 @@ export function withMainSessionTabRows(
? 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] }
const { stray } = placement
if (tabGroups.length > 0) {
tabGroups = withStrayIn(tabGroups, stray, Math.max(activeGroupIndex(windowActive), 0))
} 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))
const own = group.tabOrder.filter((id) => windowOnlyOrder.has(id))
if (own.length > 0) {
tabGroups.push({ ...group, tabOrder: own })
}
@@ -7,6 +7,7 @@ import type {
} from '../../../shared/runtime-types'
import { getMobileSessionSnapshotTabIdentityKeys } from '../mobile-session-tab-merge'
import { getRuntimeBrowserPageRegistry } from '../runtime-browser-page-registry'
import { isHeadlessBuiltPublication } from '../mobile-session-snapshot-equality'
import { sameRuntimeBrowserPlacement } from '../../../shared/runtime-browser-placement'
import type { ClientHostedBrowserRowsEvent } from '../../../shared/client-hosted-browser-rows'
@@ -130,8 +131,7 @@ export abstract class OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab ex
// snapshot must not be classified as headless-built, or its renderer tabs
// read as runtime-owned.
protected isHeadlessBuiltMobileSessionPublicationBase(publicationEpoch: string): boolean {
const base = publicationEpoch.split(':headless-merge:')[0]
return base.startsWith('headless:') || base.startsWith('headless-hydrated:')
return isHeadlessBuiltPublication(publicationEpoch)
}
protected getMergedMobileSessionPublicationEpoch(
@@ -1,14 +1,25 @@
// 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.
// subscribed phone, with no window to republish rows.
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 {
HEADLESS_LEAF_ID,
HEADLESS_SECOND_LEAF_ID,
TEST_WORKTREE_ID
} from '../orca-runtime-test-fixtures.spec'
import {
SURFACE_TAB_ID,
surfaceRuntime
} from '../terminal/surface/terminal-surface-replies.test-fixture'
import {
MOVED_STRIP,
SAVED_GROUP_ID,
SAVED_STRIP,
moveAndSplitTabs,
tabStrip,
threeTabSurfaceRuntime
} from './session-tabs-layout-moves.test-fixture'
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
@@ -41,4 +52,152 @@ describe('session.tabs on a windowless server (switch on)', () => {
vi.useRealTimers()
}
})
it("a reorder and a move to a split follow main's layout, listed and pushed", async () => {
vi.useFakeTimers()
try {
const { runtime } = threeTabSurfaceRuntime()
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
expect(tabStrip(await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`))).toEqual(
SAVED_STRIP
)
const seen: RuntimeMobileSessionTabsResult[] = []
runtime.onMobileSessionTabsChanged((snapshot) => {
seen.push(snapshot)
})
await moveAndSplitTabs(runtime)
await vi.runAllTimersAsync()
// Pushed by main on its own, and listed, with no window to republish.
expect(tabStrip(seen.at(-1))).toEqual(MOVED_STRIP)
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(tabStrip(listed)).toEqual(MOVED_STRIP)
// A second ask changes nothing: the merge is settled.
const again = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(again.snapshotVersion).toBe(listed.snapshotVersion)
} finally {
vi.useRealTimers()
}
})
it("a pane main renames shows main's title on the phone, with no window", async () => {
vi.useFakeTimers()
try {
const { runtime } = surfaceRuntime()
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
await runtime.applyLayoutCommand({
type: 'renamePane',
workspace: TEST_WORKTREE_ID,
tabId: SURFACE_TAB_ID,
leafId: HEADLESS_SECOND_LEAF_ID,
title: 'Logs'
})
await vi.runAllTimersAsync()
const held = runtime.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)
expect(held?.tabs.map((tab) => [tab.id.split('::')[1], tab.title])).toEqual([
[HEADLESS_LEAF_ID, 'Persisted Terminal'],
[HEADLESS_SECOND_LEAF_ID, 'Logs']
])
} finally {
vi.useRealTimers()
}
})
it('a list call on settled rows bumps no version and pushes nothing', async () => {
vi.useFakeTimers()
try {
const { runtime, store, setSession } = threeTabSurfaceRuntime()
// A terminal the server started, saved in the session main's layout came from.
const session = store.getSession()
const saved = session.tabsByWorktree[TEST_WORKTREE_ID] ?? []
const [first] = saved
if (!first) {
throw new Error('threeTabSurfaceRuntime saves three tabs')
}
const serveTab = { ...first, id: 'serve-tab', ptyId: 'serve-1', sortOrder: 3, createdAt: 9 }
const serveLeaf = '99999999-9999-4999-8999-999999999999'
setSession({
...session,
tabsByWorktree: { [TEST_WORKTREE_ID]: [...saved, serveTab] },
terminalLayoutsByTabId: {
...session.terminalLayoutsByTabId,
[serveTab.id]: {
root: { type: 'leaf', leafId: serveLeaf },
activeLeafId: serveLeaf,
expandedLeafId: null,
ptyIdsByLeafId: { [serveLeaf]: 'serve-1' }
}
}
})
runtime.registerPty('serve-1', TEST_WORKTREE_ID)
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
await vi.runAllTimersAsync()
const settled = runtime.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)
let pushes = 0
runtime.onMobileSessionTabsChanged(() => {
pushes += 1
})
for (let ask = 0; ask < 3; ask += 1) {
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
await runtime.listAllMobileSessionTabs()
await vi.runAllTimersAsync()
}
expect(settled?.tabs.some((tab) => tab.id.startsWith('serve-tab::'))).toBe(true)
expect(runtime.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)?.snapshotVersion).toBe(
settled?.snapshotVersion
)
expect(pushes).toBe(0)
} finally {
vi.useRealTimers()
}
})
it("a rebuild from the saved session pushes main's groups and mints no version", async () => {
vi.useFakeTimers()
try {
const { runtime } = threeTabSurfaceRuntime()
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
await vi.runAllTimersAsync()
const settled = runtime.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)
const seen: RuntimeMobileSessionTabsResult[] = []
runtime.onMobileSessionTabsChanged((snapshot) => {
seen.push(snapshot)
})
// What an editor change does: rebuild the rows from the saved session, then push. Each
// rebuild mints a new publication stamp from the clock, so time moves between them.
for (let rebuild = 0; rebuild < 3; rebuild += 1) {
vi.advanceTimersByTime(1_000)
runtime.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(TEST_WORKTREE_ID, {
force: true
})
runtime.notifyMobileSessionTabsChanged(TEST_WORKTREE_ID)
await vi.runAllTimersAsync()
}
expect(seen.map((pushed) => pushed.tabGroups?.map((group) => group.id))).toEqual([
[SAVED_GROUP_ID],
[SAVED_GROUP_ID],
[SAVED_GROUP_ID]
])
expect(tabStrip(seen.at(-1))).toEqual(SAVED_STRIP)
// A list after that rebuild serves main's groups too, through the writer.
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(listed.tabGroups?.map((group) => group.id)).toEqual([SAVED_GROUP_ID])
expect(tabStrip(listed)).toEqual(SAVED_STRIP)
expect(runtime.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)?.snapshotVersion).toBe(
settled?.snapshotVersion
)
} finally {
vi.useRealTimers()
}
})
})
@@ -0,0 +1,162 @@
// A windowless server's held session.tabs rows across main's layout commits: what a commit keeps,
// what it pushes, and which writes the row writer may skip.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HEADLESS_RUNTIME_WINDOW_ID } from '../../../shared/runtime-types'
import { HEADLESS_LEAF_ID, TEST_WORKTREE_ID } from '../orca-runtime-test-fixtures.spec'
import {
SURFACE_TAB_ID,
surfaceRuntime
} from '../terminal/surface/terminal-surface-replies.test-fixture'
import {
MOVED_STRIP,
THIRD_TAB_ID,
moveAndSplitTabs,
threeTabSurfaceRuntime
} from './session-tabs-layout-moves.test-fixture'
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
type Runtime = ReturnType<typeof surfaceRuntime>['runtime']
const held = (runtime: Runtime) => runtime.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)
const heldStrip = (runtime: Runtime): string[][] =>
(held(runtime)?.tabGroups ?? []).map((group) => group.tabOrder)
/** The rows with whole tabs in reverse order, each tab's panes as they were. */
function tabsInReverseTabOrder<Row extends { type: string; id: string; parentTabId?: string }>(
rows: readonly Row[]
): Row[] {
const tabOf = (row: Row): string => row.parentTabId ?? row.id
return [...new Set(rows.map(tabOf))]
.toReversed()
.flatMap((tab) => rows.filter((row) => tabOf(row) === tab))
}
async function windowlessRows(runtime: Runtime): Promise<void> {
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
await vi.runAllTimersAsync()
}
describe('held session.tabs rows across layout commits (switch on)', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
/** Three tabs moved and split, then the CLI closes the first: kept until L255's hook drops it. */
async function closedFirstTab(): Promise<ReturnType<typeof threeTabSurfaceRuntime>> {
const harness = threeTabSurfaceRuntime()
await windowlessRows(harness.runtime)
await moveAndSplitTabs(harness.runtime)
await vi.runAllTimersAsync()
expect(heldStrip(harness.runtime)).toEqual(MOVED_STRIP)
await harness.runtime.applyLayoutCommand({
type: 'closeTabs',
workspace: TEST_WORKTREE_ID,
tabIds: [THIRD_TAB_ID],
force: true
})
await vi.runAllTimersAsync()
return harness
}
it('a tab main closed keeps its group and place, not the active group', async () => {
const { runtime } = await closedFirstTab()
expect(heldStrip(runtime)).toEqual(MOVED_STRIP)
})
it('after it, a write that changes no row pushes nothing', async () => {
const { runtime, store, setSession } = await closedFirstTab()
let pushes = 0
runtime.onMobileSessionTabsChanged(() => {
pushes += 1
})
setSession(store.getSession())
await vi.runAllTimersAsync()
expect(pushes).toBe(0)
})
it("another workspace's commits push nothing for a workspace with a kept row", async () => {
const { runtime } = await closedFirstTab()
let pushes = 0
runtime.onMobileSessionTabsChanged((snapshot) => {
if (snapshot.worktree === TEST_WORKTREE_ID) {
pushes += 1
}
})
for (let commit = 0; commit < 3; commit += 1) {
await runtime.applyLayoutCommand({
type: 'createTerminalTab',
workspace: 'repo-1::/tmp/worktree-b',
tabId: `b-tab-${commit}`
})
await vi.runAllTimersAsync()
}
expect(pushes).toBe(0)
})
it("stores a write that names a new occupant or proof, though main's order undoes its order", async () => {
const { runtime } = threeTabSurfaceRuntime()
await windowlessRows(runtime)
const before = held(runtime)
if (!before) {
throw new Error('the list built rows')
}
const proof = {
parentTabId: 'gone',
leafId: 'gone-leaf',
ptyId: 'pty-gone',
terminal: 'term-gone'
}
runtime.storeMobileSessionSnapshot(TEST_WORKTREE_ID, {
...before,
worktreeInstanceId: 'successor',
retiredTerminalSurfaces: [proof],
tabs: tabsInReverseTabOrder(before.tabs)
})
expect(held(runtime)).toMatchObject({
worktreeInstanceId: 'successor',
retiredTerminalSurfaces: [proof]
})
// Main's order still holds.
expect(held(runtime)?.tabs).toEqual(before.tabs)
})
it('a pane main rebound takes the new terminal once it connects, with no commit', async () => {
const { runtime, bindPane } = surfaceRuntime()
await windowlessRows(runtime)
const rowId = `${SURFACE_TAB_ID}::${HEADLESS_LEAF_ID}`
const ptyOf = () => {
const row = held(runtime)?.tabs.find((tab) => tab.id === rowId)
return row?.type === 'terminal' ? row.ptyId : undefined
}
await bindPane({
worktreeId: TEST_WORKTREE_ID,
tabId: SURFACE_TAB_ID,
leafId: HEADLESS_LEAF_ID,
ptyId: 'pty-new'
})
await vi.runAllTimersAsync()
// Not running yet: the running terminal stands.
expect(ptyOf()).toBe('pty-a')
runtime.registerPty('pty-new', TEST_WORKTREE_ID, null, {
tabId: SURFACE_TAB_ID,
leafId: HEADLESS_LEAF_ID
})
await vi.runAllTimersAsync()
expect(ptyOf()).toBe('pty-new')
})
})
@@ -4,6 +4,7 @@ import type { OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree as Re
import type { OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession as HydrateHeadlessMobileSessionTabsFromWorkspaceSessionMethods } from './mobile-session-hydration'
import type { OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs as BuildHeadlessMobileSessionBrowserTabsMethods } from '../session-lifecycle/projection/build-headless-mobile-session-browser-tabs'
import type { OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals as RestoreLivePairedRendererSessionOwnedMobileTerminalsMethods } from './renderer-session-terminal-rescue'
import type { OrcaRuntimeWithTouchMobileSessionTabsForWorktree as TouchMobileSessionTabsForWorktreeMethods } from './mobile-session-membership'
import type { OrcaRuntimeWithPruneMobileSessionTabGroupLayout as PruneMobileSessionTabGroupLayoutMethods } from '../session-lifecycle/projection/prune-mobile-session-tab-group-layout'
import { OrcaRuntimeWithSyncWindowGraph } from '../session-graph/session-window-graph'
import {
@@ -37,11 +38,18 @@ export abstract class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees e
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',
live: (ptyId) => this.ptysById.get(ptyId)?.connected === true,
paneStartFact: (paneKey, binding) => this.paneStartStatuses?.get(paneKey, binding) ?? null,
readyGraphPublisher: () => (this.graphStatus === 'ready' ? this.authoritativeWindowId : null),
publish: (publisher, next) => {
this.syncWindowGraph(publisher, next)
}
},
heldRows: () => this.mobileSessionTabsByWorktree,
replaceHeldRows: (worktreeId, next) => {
this.storeMobileSessionSnapshot(worktreeId, next)
this.scheduleMobileSessionTabsChanged(worktreeId)
},
fenceExited: (rows) => this.applyMobileSessionRetirementFences(rows)
})
return mainTerminalSurfaces(this, host).withMainSurfaces(windowId, graph)
}
@@ -52,6 +60,12 @@ export abstract class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees e
>
): ReturnType<SessionTabsInventoryPublicationMethods['getAuthoritativeSessionTabsInventoryEpoch']>
protected abstract applyMobileSessionRetirementFences(
...args: Parameters<
TouchMobileSessionTabsForWorktreeMethods['applyMobileSessionRetirementFences']
>
): ReturnType<TouchMobileSessionTabsForWorktreeMethods['applyMobileSessionRetirementFences']>
protected abstract getMobileSessionTabsForWorktree(
...args: Parameters<ScheduleMobileSessionTabsChangedMethods['getMobileSessionTabsForWorktree']>
): ReturnType<ScheduleMobileSessionTabsChangedMethods['getMobileSessionTabsForWorktree']>
@@ -0,0 +1,86 @@
// Three saved tabs in one group, and the reorder plus Move to Split a desktop routes to main as
// layout commands: what session.tabs readers must then list as main's groups and order.
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'
export const SECOND_TAB_ID = '55555555-5555-4555-8555-555555555555'
export const THIRD_TAB_ID = '66666666-6666-4666-8666-666666666666'
const ADDED_TABS = [
{ id: SECOND_TAB_ID, leafId: '77777777-7777-4777-8777-777777777777' },
{ id: THIRD_TAB_ID, leafId: '88888888-8888-4888-8888-888888888888' }
]
export const SAVED_GROUP_ID = 'group-a'
/** Each group's tab order, as saved: one group. */
export const SAVED_STRIP = [[SURFACE_TAB_ID, SECOND_TAB_ID, THIRD_TAB_ID]]
/** After moveAndSplitTabs: the third tab first, the second in a split of its own. */
export const MOVED_STRIP = [[THIRD_TAB_ID, SURFACE_TAB_ID], [SECOND_TAB_ID]]
export const tabStrip = (listed: RuntimeMobileSessionTabsResult | undefined): string[][] =>
(listed?.tabGroups ?? []).map((group) => group.tabOrder)
/** The fixture's split tab plus two single-pane tabs, all in one saved group. */
export function threeTabSurfaceRuntime(): ReturnType<typeof surfaceRuntime> {
const harness = surfaceRuntime()
const session = harness.store.getSession()
const [first] = session.tabsByWorktree[TEST_WORKTREE_ID] ?? []
if (!first) {
throw new Error('surfaceRuntime saves one tab')
}
const extra = ADDED_TABS.map(({ id }, index) => ({
...first,
id,
ptyId: null,
sortOrder: index + 1,
createdAt: index + 2
}))
harness.setSession({
...session,
tabsByWorktree: { [TEST_WORKTREE_ID]: [first, ...extra] },
terminalLayoutsByTabId: {
...session.terminalLayoutsByTabId,
...Object.fromEntries(
ADDED_TABS.map(({ id, leafId }) => [
id,
{ root: { type: 'leaf' as const, leafId }, activeLeafId: leafId, expandedLeafId: null }
])
)
},
tabGroups: {
[TEST_WORKTREE_ID]: [
{
id: SAVED_GROUP_ID,
worktreeId: TEST_WORKTREE_ID,
activeTabId: SURFACE_TAB_ID,
tabOrder: SAVED_STRIP[0]!
}
]
},
activeGroupIdByWorktree: { [TEST_WORKTREE_ID]: SAVED_GROUP_ID }
})
return harness
}
/** A desktop's drag of the third tab to the front, then Move Tab to Split (right) on the second. */
export async function moveAndSplitTabs(
runtime: ReturnType<typeof surfaceRuntime>['runtime']
): Promise<void> {
await runtime.applyLayoutCommand({
type: 'moveTab',
workspace: TEST_WORKTREE_ID,
tabId: THIRD_TAB_ID,
toGroupId: SAVED_GROUP_ID,
index: 0
})
await runtime.applyLayoutCommand({
type: 'splitGroup',
workspace: TEST_WORKTREE_ID,
tabId: SECOND_TAB_ID,
besideGroupId: SAVED_GROUP_ID,
direction: 'right'
})
}
@@ -15,6 +15,14 @@ import {
SURFACE_TAB_ID,
surfaceRuntime
} from '../terminal/surface/terminal-surface-replies.test-fixture'
import {
MOVED_STRIP,
SAVED_GROUP_ID,
SAVED_STRIP,
moveAndSplitTabs,
tabStrip,
threeTabSurfaceRuntime
} from './session-tabs-layout-moves.test-fixture'
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
@@ -150,4 +158,21 @@ describe('session.tabs rows follow main’s layout (switch on)', () => {
expect(await phoneActiveRow(runtime)).toBe(row(HEADLESS_SECOND_LEAF_ID))
})
it("a reorder and a move to a split main made, before the window's groups caught up", async () => {
const { runtime } = threeTabSurfaceRuntime()
runtime.attachWindow(1)
const publication = (version: number): RuntimeMobileSessionTabsSnapshot => ({
...stalePublication(version),
activeGroupId: SAVED_GROUP_ID,
tabGroups: [{ id: SAVED_GROUP_ID, activeTabId: SURFACE_TAB_ID, tabOrder: SAVED_STRIP[0]! }]
})
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [publication(1)] })
await moveAndSplitTabs(runtime)
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [publication(2)] })
expect(tabStrip(await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`))).toEqual(
MOVED_STRIP
)
})
})
@@ -0,0 +1,87 @@
// A window reload republishes the same rows under a new publication stamp: main stores that stamp,
// so its next unchanged sync needs no republish (switch on).
import { describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-session-contracts'
import {
HEADLESS_LEAF_ID,
HEADLESS_SECOND_LEAF_ID,
TEST_WORKTREE_ID
} from '../orca-runtime-test-fixtures.spec'
import { SURFACE_TAB_ID } from '../terminal/surface/terminal-surface-replies.test-fixture'
import {
SAVED_GROUP_ID,
SECOND_TAB_ID,
THIRD_TAB_ID,
threeTabSurfaceRuntime
} from './session-tabs-layout-moves.test-fixture'
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
const terminalRow = (parentTabId: string, leafId: string, isActive = false) => ({
type: 'terminal' as const,
id: `${parentTabId}::${leafId}`,
title: 'Persisted Terminal',
parentTabId,
leafId,
isActive
})
/**
* The window's rows: its browser tab sits in a group of its own between the terminals, so main's
* order (the browser joins main's group, at its end) differs from the window's only in order.
*/
function windowRows(publicationEpoch: string): RuntimeMobileSessionTabsSnapshot {
return {
worktree: TEST_WORKTREE_ID,
publicationEpoch,
snapshotVersion: 1,
activeGroupId: SAVED_GROUP_ID,
activeTabId: `${SURFACE_TAB_ID}::${HEADLESS_LEAF_ID}`,
activeTabType: 'terminal',
tabGroups: [
{
id: SAVED_GROUP_ID,
activeTabId: SURFACE_TAB_ID,
tabOrder: [SURFACE_TAB_ID, SECOND_TAB_ID, THIRD_TAB_ID]
},
{ id: 'window-group', activeTabId: 'browser-1', tabOrder: ['browser-1'] }
],
tabs: [
terminalRow(SURFACE_TAB_ID, HEADLESS_LEAF_ID, true),
terminalRow(SURFACE_TAB_ID, HEADLESS_SECOND_LEAF_ID),
{
type: 'browser',
id: 'browser-1',
title: 'Page',
browserWorkspaceId: 'browser-1',
browserPageId: null,
url: 'https://example.com',
loading: false,
canGoBack: false,
canGoForward: false,
isActive: false
},
terminalRow(SECOND_TAB_ID, '77777777-7777-4777-8777-777777777777'),
terminalRow(THIRD_TAB_ID, '88888888-8888-4888-8888-888888888888')
]
}
}
describe('a window reload with the same rows (switch on)', () => {
it('stores the new stamp, so the next unchanged sync asks for no republish', () => {
const { runtime } = threeTabSurfaceRuntime()
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [windowRows('R1')] })
// The reload: the same rows under a new stamp.
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [windowRows('R2')] })
const next = runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
unchangedMobileSessionWorktrees: [TEST_WORKTREE_ID]
})
expect(runtime.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)?.publicationEpoch).toBe('R2')
expect(next.mobileSessionResyncWorktrees).toBeUndefined()
})
})
@@ -0,0 +1,254 @@
// A server with a window attached: a change main makes to its own layout (a CLI rename, create or
// close) reaches a subscribed phone's rows with no window publication after it.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
RuntimeMobileSessionTabsResult,
RuntimeMobileSessionTabsSnapshot
} from '../../../shared/runtime-session-contracts'
import {
HEADLESS_LEAF_ID,
HEADLESS_SECOND_LEAF_ID,
TEST_WORKTREE_ID
} from '../orca-runtime-test-fixtures.spec'
import {
SURFACE_TAB_ID,
surfaceRuntime
} from '../terminal/surface/terminal-surface-replies.test-fixture'
import {
MOVED_STRIP,
SECOND_TAB_ID,
moveAndSplitTabs,
tabStrip,
threeTabSurfaceRuntime
} from './session-tabs-layout-moves.test-fixture'
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
type Runtime = ReturnType<typeof surfaceRuntime>['runtime']
const rowOf = (leafId: string): string => `${SURFACE_TAB_ID}::${leafId}`
/** A workspace on an SSH host whose layout main has not loaded: the window's rows stand. */
const UNLOADED_SSH_ROWS: RuntimeMobileSessionTabsSnapshot = {
worktree: 'repo-ssh::/srv/remote',
publicationEpoch: 'renderer',
snapshotVersion: 1,
activeGroupId: 'ssh-group',
activeTabId: 'ssh-tab::ssh-leaf',
activeTabType: 'terminal',
tabGroups: [{ id: 'ssh-group', activeTabId: 'ssh-tab', tabOrder: ['ssh-tab'] }],
tabs: [
{
type: 'terminal',
id: 'ssh-tab::ssh-leaf',
title: 'remote shell',
parentTabId: 'ssh-tab',
leafId: 'ssh-leaf',
isActive: true
}
]
}
/** The window's one publication: both panes of the saved split, under their saved title. */
function windowPublication(): RuntimeMobileSessionTabsSnapshot {
return {
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'renderer',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: rowOf(HEADLESS_LEAF_ID),
activeTabType: 'terminal',
tabs: [HEADLESS_LEAF_ID, HEADLESS_SECOND_LEAF_ID].map((leafId) => ({
type: 'terminal' as const,
id: rowOf(leafId),
title: 'Persisted Terminal',
parentTabId: SURFACE_TAB_ID,
leafId,
isActive: leafId === HEADLESS_LEAF_ID
}))
}
}
/** Attaches a window that publishes once, then records every push a phone gets. */
async function windowedPhone(runtime: Runtime): Promise<RuntimeMobileSessionTabsResult[]> {
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: [windowPublication(), UNLOADED_SSH_ROWS]
})
await vi.runAllTimersAsync()
const seen: RuntimeMobileSessionTabsResult[] = []
runtime.onMobileSessionTabsChanged((snapshot) => {
seen.push(snapshot)
})
return seen
}
const listed = (runtime: Runtime) => runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
const parentTabsOf = (
rows: Pick<RuntimeMobileSessionTabsResult | RuntimeMobileSessionTabsSnapshot, 'tabs'> | undefined
): string[] => [
...new Set((rows?.tabs ?? []).map((tab) => (tab.type === 'terminal' ? tab.parentTabId : tab.id)))
]
/** The terminal tabs main's layout holds for the workspace, in saved order. */
function layoutTabIds(store: ReturnType<typeof surfaceRuntime>['store']): string[] {
return (store.getSession().tabsByWorktree[TEST_WORKTREE_ID] ?? []).map((tab) => tab.id)
}
describe('session.tabs on a windowed server follow main without a window publish (switch on)', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('a CLI rename reaches the pushed and listed rows', async () => {
const { runtime } = surfaceRuntime()
const seen = await windowedPhone(runtime)
await runtime.applyLayoutCommand({
type: 'renameTab',
workspace: TEST_WORKTREE_ID,
tabId: SURFACE_TAB_ID,
kind: 'custom',
title: 'Build'
})
await vi.runAllTimersAsync()
const named = (rows: RuntimeMobileSessionTabsResult | undefined) =>
(rows?.tabs ?? []).map((tab) => [tab.title, tab.type === 'terminal' && tab.customTitle])
// The manual name too, which a paired desktop reads to keep or replace its own.
expect(named(seen.at(-1))).toEqual([
['Build', 'Build'],
['Build', 'Build']
])
expect(named(await listed(runtime))).toEqual([
['Build', 'Build'],
['Build', 'Build']
])
})
it('a CLI create reaches the pushed and listed rows', async () => {
const { runtime, store } = surfaceRuntime()
const seen = await windowedPhone(runtime)
await runtime.applyLayoutCommand({
type: 'createTerminalTab',
workspace: TEST_WORKTREE_ID,
tabId: SECOND_TAB_ID
})
await vi.runAllTimersAsync()
expect(layoutTabIds(store)).toEqual([SURFACE_TAB_ID, SECOND_TAB_ID])
expect(parentTabsOf(seen.at(-1))).toEqual([SURFACE_TAB_ID, SECOND_TAB_ID])
expect(parentTabsOf(await listed(runtime))).toEqual([SURFACE_TAB_ID, SECOND_TAB_ID])
})
// Stale until W4's dropped-leaf hook (its close PR, ledger L255) drops the row; remove .fails then.
it.fails("a CLI close reaches the rows, which then agree with main's layout", async () => {
const { runtime, store } = threeTabSurfaceRuntime()
const seen = await windowedPhone(runtime)
expect(parentTabsOf(await listed(runtime))).toContain(SECOND_TAB_ID)
await runtime.applyLayoutCommand({
type: 'closeTabs',
workspace: TEST_WORKTREE_ID,
tabIds: [SECOND_TAB_ID],
force: true
})
await vi.runAllTimersAsync()
expect(layoutTabIds(store)).not.toContain(SECOND_TAB_ID)
expect(parentTabsOf(seen.at(-1))).toEqual(layoutTabIds(store))
expect(parentTabsOf(await listed(runtime))).toEqual(layoutTabIds(store))
})
it("a later commit, or the window's older snapshot, never re-adds a row main's layout lacks", async () => {
const { runtime } = threeTabSurfaceRuntime()
await windowedPhone(runtime)
await runtime.applyLayoutCommand({
type: 'closeTabs',
workspace: TEST_WORKTREE_ID,
tabIds: [SECOND_TAB_ID],
force: true
})
await vi.runAllTimersAsync()
// The interim phone-close drop: a writer stores the rows without the closed tab.
const held = runtime.mobileSessionTabsByWorktree.get(TEST_WORKTREE_ID)
if (!held) {
throw new Error('the window published rows')
}
runtime.storeMobileSessionSnapshot(TEST_WORKTREE_ID, {
...held,
tabs: held.tabs.filter((tab) => !parentTabsOf({ tabs: [tab] }).includes(SECOND_TAB_ID))
})
await runtime.applyLayoutCommand({
type: 'renameTab',
workspace: TEST_WORKTREE_ID,
tabId: SURFACE_TAB_ID,
kind: 'custom',
title: 'Build'
})
await vi.runAllTimersAsync()
// The window republishes its older snapshot, which still lists the closed tab.
const older = windowPublication()
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: [
{
...older,
snapshotVersion: 2,
tabs: [
...older.tabs,
...held.tabs.filter((tab) => parentTabsOf({ tabs: [tab] }).includes(SECOND_TAB_ID))
]
}
]
})
await vi.runAllTimersAsync()
expect(parentTabsOf(await listed(runtime))).not.toContain(SECOND_TAB_ID)
})
it('a workspace on an unloaded SSH host keeps the rows its window published', async () => {
const { runtime } = surfaceRuntime()
await windowedPhone(runtime)
const before = runtime.mobileSessionTabsByWorktree.get(UNLOADED_SSH_ROWS.worktree)
await runtime.applyLayoutCommand({
type: 'renameTab',
workspace: TEST_WORKTREE_ID,
tabId: SURFACE_TAB_ID,
kind: 'custom',
title: 'Build'
})
await vi.runAllTimersAsync()
runtime.storeMobileSessionSnapshot(UNLOADED_SSH_ROWS.worktree, {
...UNLOADED_SSH_ROWS,
snapshotVersion: 2
})
expect(before?.tabs.map((tab) => tab.id)).toEqual(['ssh-tab::ssh-leaf'])
const after = runtime.mobileSessionTabsByWorktree.get(UNLOADED_SSH_ROWS.worktree)
expect(after?.tabs).toEqual(UNLOADED_SSH_ROWS.tabs)
expect(after?.tabGroups).toEqual(UNLOADED_SSH_ROWS.tabGroups)
})
it("after the window closes, main's later moves reach the phone, as no-window rows", async () => {
const { runtime } = threeTabSurfaceRuntime()
const seen = await windowedPhone(runtime)
runtime.markGraphUnavailable(1)
await vi.runAllTimersAsync()
await moveAndSplitTabs(runtime)
await vi.runAllTimersAsync()
expect(tabStrip(seen.at(-1))).toEqual(MOVED_STRIP)
expect(tabStrip(await listed(runtime))).toEqual(MOVED_STRIP)
})
})
@@ -9,6 +9,8 @@ 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 { withMainTabOrder } from '../../session-tabs/main-session-tab-order'
import { headlessMobileSnapshotContentUnchanged } from '../../mobile-session-snapshot-equality'
import type { RuntimeStore } from '../../runtime-store-contract'
import type { WorkspaceLayoutReads } from '../../workspace-layout-stream'
import {
@@ -26,12 +28,20 @@ export type MainTerminalSurfacesHost = {
registered: (ptyId: string) => boolean
/** Whether the host certified the terminal exited (not merely disconnected or unverifiable). */
exited: (ptyId: string) => boolean
/** Whether the terminal runs now. */
live: (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. */
readyGraphPublisher: () => number | null
/** Installs a graph under the publisher's authority, as the window's own publication does. */
publish: (windowId: number, graph: RuntimeSyncWindowGraph) => void
/** The session.tabs rows the runtime holds, by workspace. */
heldRows: () => ReadonlyMap<string, RuntimeMobileSessionTabsSnapshot>
/** Stores a workspace's rows and pushes them to its subscribers. */
replaceHeldRows: (worktreeId: string, next: RuntimeMobileSessionTabsSnapshot) => void
/** The rows less every terminal the window path's exit filters retire. */
fenceExited: (rows: RuntimeMobileSessionTabsSnapshot) => RuntimeMobileSessionTabsSnapshot
}
class MainSurfacePublisher {
@@ -42,6 +52,7 @@ class MainSurfacePublisher {
private installed: MainTerminalSurfaces | null = null
private scheduled = false
private forceNext = false
private mergeNext = false
constructor(private readonly host: MainTerminalSurfacesHost) {
this.index = new TerminalSurfaceIndex({
@@ -49,7 +60,7 @@ class MainSurfacePublisher {
hostIds: () => host.store()?.getWorkspaceSessionHostIds?.() ?? [],
homeHostId: host.homeHostId,
generatedTitlesEnabled: () => host.store()?.getSettings?.().tabAutoGenerateTitle === true,
onChanged: () => this.schedule()
onChanged: () => this.schedule({ merge: true })
})
}
@@ -71,12 +82,41 @@ class MainSurfacePublisher {
private withMainRows(
published: RuntimeMobileSessionTabsSnapshot
): RuntimeMobileSessionTabsSnapshot {
const projected = this.installed?.worktreeIds.has(published.worktree)
? this.index.sessionTabsOf(published.worktree)
: null
const projected = this.index.lastSessionTabsOf(published.worktree)
return projected ? withMainSessionTabRows(published, projected, this.host.exited) : published
}
/** Rows a writer stores, ordered and grouped as main's last projection has them: no read. */
withMainOrder(written: RuntimeMobileSessionTabsSnapshot): RuntimeMobileSessionTabsSnapshot {
const projected = this.index.lastSessionTabsOf(written.worktree)
return projected ? withMainTabOrder(written, projected) : written
}
/**
* After a layout commit: held rows take main's new rows, names and order. A row main's layout
* lacks stays (drops are the close path's); an added terminal passes the window's exit filters.
*/
private mergeIntoHeldRows(): void {
this.index.surfaces()
for (const [worktreeId, held] of this.host.heldRows()) {
const projected = this.index.lastSessionTabsOf(held.worktree)
if (!projected) {
continue
}
const merged = withMainSessionTabRows(held, projected, this.host.exited, {
live: this.host.live,
keepUnlisted: true
})
const heldIds = new Set(held.tabs.map((row) => row.id))
const next = merged.tabs.some((row) => row.type === 'terminal' && !heldIds.has(row.id))
? this.host.fenceExited(merged)
: merged
if (!headlessMobileSnapshotContentUnchanged(held, next)) {
this.host.replaceHeldRows(worktreeId, next)
}
}
}
/** The pane main's layout binds a terminal to, read fresh; null when no pane main owns holds it. */
findPaneForPty(ptyId: string): { worktreeId: string; tabId: string; leafId: string } | null {
const pane = this.index.surfaces().leaves.find((leaf) => leaf.ptyId === ptyId)
@@ -162,21 +202,27 @@ class MainSurfacePublisher {
/** A pane's terminal came or went: its record changes with no layout change, so reinstall. */
terminalChanged(): void {
this.index.invalidateRouting()
this.schedule({ force: true })
// A binding main made before its terminal connected reaches the held rows now.
this.schedule({ force: true, merge: true })
}
/** Deferred so a connected flip, noted inside syncWindowGraph's record update, cannot recurse into it. */
private schedule(opts: { force?: boolean } = {}): void {
private schedule(opts: { force?: boolean; merge?: boolean } = {}): void {
this.forceNext ||= opts.force === true
this.mergeNext ||= opts.merge === true
if (this.scheduled) {
return
}
this.scheduled = true
queueMicrotask(() => {
const force = this.forceNext
const { forceNext: force, mergeNext: merge } = this
this.scheduled = false
this.forceNext = false
this.mergeNext = false
this.republish({ force })
if (merge) {
this.mergeIntoHeldRows()
}
})
}
}
@@ -37,6 +37,7 @@ export class TerminalSurfaceIndex {
private currentJson = ''
/** The loaded partitions `current` was projected from: the projection's own cached objects. */
private projectedFrom: (LoadedWorkspaceLayout | null)[] = []
private projectedHostIds: readonly ExecutionHostId[] = []
private projectedHomes = ''
private projectedTitles: boolean | null = null
/** Workspace key → its home partition, as of the last projection. */
@@ -112,6 +113,7 @@ export class TerminalSurfaceIndex {
}
this.projectedTitles = titles
this.projectedFrom = loaded
this.projectedHostIds = hostIds
this.projectedHomes = homes
const next = this.project(hostIds, loaded)
const json = JSON.stringify({
@@ -182,11 +184,16 @@ 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')
return this.lastSessionTabsOf(worktreeId)
}
/** As sessionTabsOf, from the partitions last projected: reads no session. */
lastSessionTabsOf(worktreeId: string): ProjectedSessionTabs | null {
if (!this.current?.worktreeIds.has(worktreeId)) {
return null
}
for (const [index, partition] of this.projectedFrom.entries()) {
const hostId = hostIds[index]
const hostId = this.projectedHostIds[index]
for (const [key, workspace] of Object.entries(partition?.layout.workspaces ?? {})) {
if (partition && workspace.worktreeId === worktreeId && this.routes.get(key) === hostId) {
return projectSessionTabs(