diff --git a/src/main/runtime/construction/runtime-identity-state.ts b/src/main/runtime/construction/runtime-identity-state.ts index 9e39d2e0c11..ff2377fcb7f 100644 --- a/src/main/runtime/construction/runtime-identity-state.ts +++ b/src/main/runtime/construction/runtime-identity-state.ts @@ -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) diff --git a/src/main/runtime/mobile-session-snapshot-equality.ts b/src/main/runtime/mobile-session-snapshot-equality.ts index 354886787de..51769b8c50e 100644 --- a/src/main/runtime/mobile-session-snapshot-equality.ts +++ b/src/main/runtime/mobile-session-snapshot-equality.ts @@ -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)) + ) +} diff --git a/src/main/runtime/session-tabs/main-session-tab-commit-merge.test.ts b/src/main/runtime/session-tabs/main-session-tab-commit-merge.test.ts new file mode 100644 index 00000000000..b8cf242ae3f --- /dev/null +++ b/src/main/runtime/session-tabs/main-session-tab-commit-merge.test.ts @@ -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']]) + }) +}) diff --git a/src/main/runtime/session-tabs/main-session-tab-group-placement.ts b/src/main/runtime/session-tabs/main-session-tab-group-placement.ts new file mode 100644 index 00000000000..6e69cf49190 --- /dev/null +++ b/src/main/runtime/session-tabs/main-session-tab-group-placement.ts @@ -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[] { + 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, + 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 + ) +} diff --git a/src/main/runtime/session-tabs/main-session-tab-order.test.ts b/src/main/runtime/session-tabs/main-session-tab-order.test.ts new file mode 100644 index 00000000000..66c98a00f0c --- /dev/null +++ b/src/main/runtime/session-tabs/main-session-tab-order.test.ts @@ -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) + }) +}) diff --git a/src/main/runtime/session-tabs/main-session-tab-order.ts b/src/main/runtime/session-tabs/main-session-tab-order.ts new file mode 100644 index 00000000000..621b0df1d77 --- /dev/null +++ b/src/main/runtime/session-tabs/main-session-tab-order.ts @@ -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 + } +} diff --git a/src/main/runtime/session-tabs/main-session-tab-rows.ts b/src/main/runtime/session-tabs/main-session-tab-rows.ts index 35c6ff242df..a90aa8d7060 100644 --- a/src/main/runtime/session-tabs/main-session-tab-rows.ts +++ b/src/main/runtime/session-tabs/main-session-tab-rows.ts @@ -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): 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[] { - 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 = {} ): 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 }) } diff --git a/src/main/runtime/session-tabs/mobile-session-publication-preservation.ts b/src/main/runtime/session-tabs/mobile-session-publication-preservation.ts index 39d36adeb23..ef1914e6786 100644 --- a/src/main/runtime/session-tabs/mobile-session-publication-preservation.ts +++ b/src/main/runtime/session-tabs/mobile-session-publication-preservation.ts @@ -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( diff --git a/src/main/runtime/session-tabs/session-tabs-headless-main-change.test.ts b/src/main/runtime/session-tabs/session-tabs-headless-main-change.test.ts index 0cc3f59e83c..4d7ba678f99 100644 --- a/src/main/runtime/session-tabs/session-tabs-headless-main-change.test.ts +++ b/src/main/runtime/session-tabs/session-tabs-headless-main-change.test.ts @@ -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() + } + }) }) diff --git a/src/main/runtime/session-tabs/session-tabs-held-rows-commit.test.ts b/src/main/runtime/session-tabs/session-tabs-held-rows-commit.test.ts new file mode 100644 index 00000000000..583b3b78b82 --- /dev/null +++ b/src/main/runtime/session-tabs/session-tabs-held-rows-commit.test.ts @@ -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['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( + 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 { + 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> { + 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') + }) +}) diff --git a/src/main/runtime/session-tabs/session-tabs-inventory.ts b/src/main/runtime/session-tabs/session-tabs-inventory.ts index 8853f09bb0a..f5b3b34cbfe 100644 --- a/src/main/runtime/session-tabs/session-tabs-inventory.ts +++ b/src/main/runtime/session-tabs/session-tabs-inventory.ts @@ -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 + protected abstract applyMobileSessionRetirementFences( + ...args: Parameters< + TouchMobileSessionTabsForWorktreeMethods['applyMobileSessionRetirementFences'] + > + ): ReturnType + protected abstract getMobileSessionTabsForWorktree( ...args: Parameters ): ReturnType diff --git a/src/main/runtime/session-tabs/session-tabs-layout-moves.test-fixture.ts b/src/main/runtime/session-tabs/session-tabs-layout-moves.test-fixture.ts new file mode 100644 index 00000000000..1aca0ed6672 --- /dev/null +++ b/src/main/runtime/session-tabs/session-tabs-layout-moves.test-fixture.ts @@ -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 { + 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['runtime'] +): Promise { + 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' + }) +} diff --git a/src/main/runtime/session-tabs/session-tabs-main-rows-parity.test.ts b/src/main/runtime/session-tabs/session-tabs-main-rows-parity.test.ts index c7b0d38194f..24ef6fe101b 100644 --- a/src/main/runtime/session-tabs/session-tabs-main-rows-parity.test.ts +++ b/src/main/runtime/session-tabs/session-tabs-main-rows-parity.test.ts @@ -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 + ) + }) }) diff --git a/src/main/runtime/session-tabs/session-tabs-window-reload-stamp.test.ts b/src/main/runtime/session-tabs/session-tabs-window-reload-stamp.test.ts new file mode 100644 index 00000000000..3d82f216bed --- /dev/null +++ b/src/main/runtime/session-tabs/session-tabs-window-reload-stamp.test.ts @@ -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() + }) +}) diff --git a/src/main/runtime/session-tabs/session-tabs-windowed-main-change.test.ts b/src/main/runtime/session-tabs/session-tabs-windowed-main-change.test.ts new file mode 100644 index 00000000000..aabb0712286 --- /dev/null +++ b/src/main/runtime/session-tabs/session-tabs-windowed-main-change.test.ts @@ -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['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 { + 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 | 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['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) + }) +}) diff --git a/src/main/runtime/terminal/surface/main-terminal-surfaces.ts b/src/main/runtime/terminal/surface/main-terminal-surfaces.ts index ff9b31c86fd..65f739c3598 100644 --- a/src/main/runtime/terminal/surface/main-terminal-surfaces.ts +++ b/src/main/runtime/terminal/surface/main-terminal-surfaces.ts @@ -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 + /** 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() + } }) } } diff --git a/src/main/runtime/terminal/surface/terminal-surface-index.ts b/src/main/runtime/terminal/surface/terminal-surface-index.ts index 7a87b778432..a4a62908c1b 100644 --- a/src/main/runtime/terminal/surface/terminal-surface-index.ts +++ b/src/main/runtime/terminal/surface/terminal-surface-index.ts @@ -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(