From 6e589bb4baf87f586b7d060e5d7b1b2d176ee330 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:10:25 -0400 Subject: [PATCH] Layout-owner build: phones see main's terminal and editor rows (A2, dark) (#31276) * wip(layout): applyDefaultTabs command in the layout model (dark) * wip(layout): applyDefaultTabs takes the window's proposed ids; main refuses a taken one * wip(layout): main closes or keeps an exited split pane by the window's rule (dark) * wip(layout): a create or split sent again after its close is refused (dark) * wip: typed test doubles * wip(layout): main writes a worktree's default tabs in one command and starts them, window or none (dark; twin e) * wip(layout): main writes a worktree's default tabs in one command and starts them, window or none (dark; twin e) * wip(layout): main provisions setup and default tabs even when the agent fails to start; no window takes over (dark) * wip(layout): pane start status in frames; a failed start keeps its pane; a terminal close stands after a failed write (dark) * wip: lint fixes (max-lines, test throws) * test(layout): a terminal close stands after a real failed profile write; switch off still refuses * fix(layout): checked types in the close-failure rule and its tests * fix(layout): a failed start keeps its pane whatever failed; default tabs start once; a tab's last pane keeps its card (dark) * test(layout): the agent's kept pane counts as the workspace's terminal; main starts it whatever failed * fix(layout): the switch-off create path gains no await * fix(layout): a failed start never writes back a pane the user closed (dark) * fix(layout): a failed start's pane goes through main's create rules, so a closed tab stays closed (dark) * test(layout): D1a's switch-on close tests assert the intent main receives (both builds) * test(layout): the close fixture answers main's command without naming the method * fix(layout): a re-created pane binds as designed; the id-only ratchet reads a shared import (dark) * fix(layout): a pane's start status may sit on a pane still bound to its old terminal (dark) * fix(layout): a pane's start status ends when its pane binds again by any path (dark) * test(layout): same-id restore ends a status; a replayed split brings back a closed pane by design * test(layout): terminal reply fixtures captured from the window-owned build, and main's switch-on proofs * wip(layout): main's terminal surface index feeds the runtime graph (dark) * wip(layout): main's panes stand in for the window's terminal list (dark) * test(layout): the window sends main-listed panes as mount facts (dark) * test(layout): surface proofs use public runtime calls * chore: lint * feat(layout): close by handle closes in main's layout; the close-time relist goes (dark) * feat(layout): main's binding outranks a caller's pane claim on the terminal record (dark) * test(layout): window-list tests stay on the rollback build; switch-on mount-fact cases (dark) * wip(layout): main's terminal index reads E1's shared projection and follows its operation ends (dark) * refactor(layout): the main-surfaces host lives beside the session-tabs readers, keeping the graph file under its line cap (dark) * fix(layout): main's panes skip the window-era membership fence, follow workspace routing, and list a terminal when it registers; window close only hands the graph back (dark) * refactor(runtime): explicit-close PTY stops move unchanged to their own module, for the file's line cap * test(layout): the cold-restore table runs in a layout-owner build too: one push per registration * test(layout): the parked and automation pane tests assert the same window report in both builds (mount facts with the switch on) * chore: one layout-owner switch import after the restack on D1a * feat(layout): main's terminal.list rows carry the host's verdict; a pane with a status is never live (dark) * chore(layout): fit main's surfaces under the lint gates after the D1a restack * feat(layout): every pane status D1a publishes keeps a row from reading live; remote host answers pass through (dark) * feat(layout): a drag-out moves main's own binding and refuses the home copy's last pane; the window's terminal claim is not consulted (L127, dark) * fix(layout): a tab close by a starting pane's handle stops the tab's terminals; a starting pane is never exited on the inventory alone; terminal.list lists every saved pane main owns; the pane claim lookup leaves the output path (dark) * fix(terminal): keep shipped exit and default-tab behaviour with the layout switch on An exited pane closes through the legacy retirement unless the keep rule holds; the default tab's title and color still reach the window and the phone's live tabs. * fix(terminal): one id guard for every pane write; keep panes on an unvouched -1 Main's taken and closed-id guard now runs where every pane write goes, so a runtime create replayed after its tab closed is refused before it spawns. An exit code nothing vouches for keeps the pane, as the window does; a kept pane sends the phone no retirement. Converts the switch-on tests that asserted the deleted window hand-off and title bridge. * fix(terminal): keep the closed-tab guard on every pane write, held-id checks on client commands A paired host starts each pane of a client's tab as a create under the tab's id, so a held tab id is refused only for a client's own layout command. * fix(terminal): a provider-reported -1 keeps the pane, as the window does Only a requested stop or a host-confirmed death closes a pane on a negative exit code. Pins that the closed-tab guard never fires in the shipped build. * chore(terminal): satisfy the exhaustive-switch and test typing checks * test(terminal): give the replay test's live process a cwd * fix(layout): main's index reads routing only for a workspace two partitions hold, the fence reads ownership once, a legacy pane keeps its claim; switch-on tests assert main's answers after a window closes (dark) * refactor(runtime): a worktree stop's terminal collection moves unchanged to its own module, for the file's line cap * fix(layout): main's index routes every workspace as the layout stream does, re-reading routing only on a write or a terminal registration; a worktree stop names every terminal main binds; a starting pane is unverifiable (dark) * feat(layout): a workspace main owns publishes its terminal and editor session.tabs rows from main's layout; the window still supplies browser, chat and view-only rows until D3b (A2, dark) * chore: lint * test: type the row fixtures * chore(layout): main's session.tabs projection is an old-client entry, imported only by main's terminal surface index * fix(layout): main's session.tabs rows carry the tab's name, the window's agent status and markdown file id, list rows in tab order, and keep groups and selection consistent (A2, dark) * refactor(layout): the projection's group order moves unchanged to its own old-client module, for the file's line cap; lint * feat(layout): every pane status main publishes keeps a row from reading live; a resuming pane is unverifiable like a starting one (dark) * test(layout): a windowless server's layout change reaches a subscribed phone's session.tabs rows (dark) * fix(layout): a window-only row keeps its place in the rows, the window's selected row lands in a group that selects it, and a workspace with no main group keeps the window's groups (A2, dark) * fix(layout): a pane main has no binding for yet keeps the window's terminal in its phone row, so an SSH restore lists it pending (A2, dark; ledger L243) * fix(session-tabs): phones show the window's live selection, focused pane, dirty state and titles over main's saved view; exited terminals stay gone; stray rows join the active group (switch-on builds only) * fix(session-tabs): main's groups stand with main's selection; certified exits only; a selected stray row joins main's active group --------- Co-authored-by: E2E Test --- .../scripts/check-compat-boundary-imports.mjs | 7 +- .../main-session-tab-rows.test.ts | 317 ++++++++++++++++++ .../session-tabs/main-session-tab-rows.ts | 227 +++++++++++++ .../session-tabs-headless-main-change.test.ts | 44 +++ .../session-tabs/session-tabs-inventory.ts | 2 + .../session-tabs-main-rows-parity.test.ts | 153 +++++++++ .../surface/main-terminal-surfaces.ts | 22 +- .../surface/terminal-surface-index.ts | 27 ++ .../old-client/old-client-group-order.ts | 18 + ...old-client-session-tabs-projection.test.ts | 3 + .../old-client-session-tabs-projection.ts | 19 +- 11 files changed, 820 insertions(+), 19 deletions(-) create mode 100644 src/main/runtime/session-tabs/main-session-tab-rows.test.ts create mode 100644 src/main/runtime/session-tabs/main-session-tab-rows.ts create mode 100644 src/main/runtime/session-tabs/session-tabs-headless-main-change.test.ts create mode 100644 src/main/runtime/session-tabs/session-tabs-main-rows-parity.test.ts create mode 100644 src/shared/workspace-layout/old-client/old-client-group-order.ts diff --git a/config/scripts/check-compat-boundary-imports.mjs b/config/scripts/check-compat-boundary-imports.mjs index b619617eeae..e9ae77efc64 100644 --- a/config/scripts/check-compat-boundary-imports.mjs +++ b/config/scripts/check-compat-boundary-imports.mjs @@ -10,8 +10,11 @@ import { isTestOnlySourcePath } from './test-only-source-path.mjs' export const COMPAT_FOLDERS = [ { folder: 'src/shared/workspace-layout/old-client', - entries: ['old-client-session-tab-rows'], - importers: ['src/main/runtime/terminal/launch/mobile-terminal-chat-fork-create.ts'] + entries: ['old-client-session-tab-rows', 'old-client-session-tabs-projection'], + importers: [ + 'src/main/runtime/terminal/launch/mobile-terminal-chat-fork-create.ts', + 'src/main/runtime/terminal/surface/terminal-surface-index.ts' + ] }, { folder: 'src/main/remote-layout-snapshot', diff --git a/src/main/runtime/session-tabs/main-session-tab-rows.test.ts b/src/main/runtime/session-tabs/main-session-tab-rows.test.ts new file mode 100644 index 00000000000..cc4ed43bb0a --- /dev/null +++ b/src/main/runtime/session-tabs/main-session-tab-rows.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it } from 'vitest' +import type { + RuntimeMobileSessionSnapshotTab, + RuntimeMobileSessionTerminalTab +} from '../../../shared/runtime-mobile-session-tab-contracts' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-session-contracts' +import { withMainSessionTabRows, type MainSessionTabRows } from './main-session-tab-rows' + +const WT = 'repo::/wt' +const NONE_EXITED = (): boolean => false + +function terminal( + id: string, + extra: Partial = {} +): RuntimeMobileSessionTerminalTab { + return { + type: 'terminal' as const, + id, + title: 'Terminal', + parentTabId: id.split('::')[0]!, + leafId: id.split('::')[1]!, + isActive: false, + ...extra + } +} + +function file(id: string, mode: 'edit' | 'diff' = 'edit') { + return { + type: 'file' as const, + id, + title: id, + filePath: `/wt/${id}`, + relativePath: id, + language: 'typescript', + mode, + isDirty: false, + isActive: false + } +} + +function browser(id: string) { + return { + type: 'browser' as const, + id, + title: 'Page', + browserWorkspaceId: id, + browserPageId: null, + url: 'https://example.com', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: false + } +} + +function windowSnapshot( + tabs: RuntimeMobileSessionSnapshotTab[], + tabOrder: string[], + activeTabId: string | null +): RuntimeMobileSessionTabsSnapshot { + const active = tabs.find((tab) => tab.id === activeTabId) + return { + worktree: WT, + publicationEpoch: 'renderer', + snapshotVersion: 7, + activeGroupId: 'g', + activeTabId, + activeTabType: active?.type ?? null, + tabGroups: [{ id: 'g', activeTabId, tabOrder }], + tabs: tabs.map((tab) => ({ ...tab, isActive: tab.id === activeTabId })) + } +} + +function mainProjection( + tabs: RuntimeMobileSessionSnapshotTab[], + tabOrder: string[], + activeTabId: string | null +): MainSessionTabRows { + const { + publicationEpoch: _e, + snapshotVersion: _v, + ...rest + } = windowSnapshot(tabs, tabOrder, activeTabId) + return rest +} + +describe('session.tabs rows from main (layout-owner build, before D3b)', () => { + it("takes main's terminal rows and name, with the window's theme, status and draft", () => { + const theme = { mode: 'dark' as const, theme: {} } + const agentStatus = null + const merged = withMainSessionTabRows( + windowSnapshot( + [ + terminal('t::a', { + title: 'Old', + customTitle: 'Old', + terminalTheme: theme, + agentStatus, + launchDraft: 'ls' + }) + ], + ['t'], + 't::a' + ), + mainProjection( + [terminal('t::a', { title: 'Renamed', customTitle: 'Renamed' }), terminal('t::b')], + ['t'], + 't::a' + ), + NONE_EXITED + ) + + expect(merged.publicationEpoch).toBe('renderer') + expect(merged.snapshotVersion).toBe(7) + expect(merged.tabs).toEqual([ + terminal('t::a', { + title: 'Renamed', + customTitle: 'Renamed', + terminalTheme: theme, + agentStatus, + launchDraft: 'ls', + isActive: true + }), + terminal('t::b') + ]) + }) + + it('keeps the rows main does not hold (browser, diff) in the window’s place', () => { + const merged = withMainSessionTabRows( + windowSnapshot( + [terminal('t::a'), browser('b1'), file('d1', 'diff'), file('f1')], + ['t', 'b1', 'd1', 'f1'], + 't::a' + ), + mainProjection([terminal('t::a'), browser('b1'), file('f1')], ['t', 'b1', 'f1'], 't::a'), + NONE_EXITED + ) + + expect(merged.tabs.map((tab) => tab.id)).toEqual(['t::a', 'b1', 'd1', 'f1']) + expect(merged.tabGroups?.[0]?.tabOrder).toEqual(['t', 'b1', 'd1', 'f1']) + }) + + // #30569: a closed editor leaves no stale active id behind. + it('drops an editor main closed, and its stale active id, though the window still lists it', () => { + const merged = withMainSessionTabRows( + windowSnapshot([terminal('t::a'), file('f1')], ['t', 'f1'], 'f1'), + mainProjection([terminal('t::a')], ['t'], 't::a'), + NONE_EXITED + ) + + expect(merged.tabs.map((tab) => tab.id)).toEqual(['t::a']) + expect(merged).toMatchObject({ activeTabId: 't::a', activeTabType: 'terminal' }) + expect(merged.tabs.filter((tab) => tab.isActive).map((tab) => tab.id)).toEqual(['t::a']) + expect(merged.tabGroups?.[0]?.tabOrder).toEqual(['t']) + }) + + it("keeps the window's selection while it shows a row main does not list", () => { + const merged = withMainSessionTabRows( + windowSnapshot([terminal('t::a'), file('d1', 'diff')], ['t', 'd1'], 'd1'), + mainProjection([terminal('t::a')], ['t'], 't::a'), + NONE_EXITED + ) + + expect(merged).toMatchObject({ activeTabId: 'd1', activeTabType: 'file' }) + expect(merged.tabGroups?.[0]?.activeTabId).toBe('d1') + expect(merged.tabs.filter((tab) => tab.isActive).map((tab) => tab.id)).toEqual(['d1']) + }) + + it('moves the window-selected row of a group main lacks into a group, and selects that group', () => { + const published: RuntimeMobileSessionTabsSnapshot = { + ...windowSnapshot([terminal('t::a'), file('d1', 'diff')], ['t'], 'd1'), + activeGroupId: 'g2', + tabGroups: [ + { id: 'g', activeTabId: 't', tabOrder: ['t'] }, + { id: 'g2', activeTabId: 'd1', tabOrder: ['d1'] } + ] + } + const merged = withMainSessionTabRows( + published, + mainProjection([terminal('t::a')], ['t'], 't::a'), + NONE_EXITED + ) + + expect(merged.tabGroups).toEqual([{ id: 'g', activeTabId: 'd1', tabOrder: ['t', 'd1'] }]) + expect(merged).toMatchObject({ activeGroupId: 'g', activeTabId: 'd1' }) + }) + + it("takes the window's live selection, focused pane and dirty state, not main's saved view", () => { + const merged = withMainSessionTabRows( + windowSnapshot( + [terminal('t::a'), terminal('t::b', { title: 'vim' }), { ...file('f1'), isDirty: true }], + ['t', 'f1'], + 't::b' + ), + mainProjection([terminal('t::a'), terminal('t::b'), file('f1')], ['t', 'f1'], 't::a'), + NONE_EXITED + ) + + expect(merged).toMatchObject({ activeTabId: 't::b', activeTabType: 'terminal' }) + expect(merged.tabGroups?.[0]?.activeTabId).toBe('t') + expect(merged.tabs.find((tab) => tab.id === 't::b')).toMatchObject({ title: 'vim' }) + expect(merged.tabs.find((tab) => tab.id === 'f1')).toMatchObject({ isDirty: true }) + }) + + it("keeps main's selection while the window selects a pane main has not drawn yet", () => { + const merged = withMainSessionTabRows( + windowSnapshot([terminal('t::a'), terminal('t::new')], ['t'], 't::new'), + mainProjection([terminal('t::a')], ['t'], 't::a'), + NONE_EXITED + ) + + expect(merged).toMatchObject({ activeTabId: 't::a' }) + expect(merged.tabs.map((tab) => tab.id)).toEqual(['t::a']) + }) + + it("lets main's cleared binding win over the window's id of a terminal main saw exit", () => { + const published = windowSnapshot([terminal('t::a', { ptyId: 'pty-gone' })], ['t'], 't::a') + const projected = mainProjection([terminal('t::a')], ['t'], 't::a') + + expect( + withMainSessionTabRows(published, projected, (id) => id === 'pty-gone').tabs[0] + ).not.toHaveProperty('ptyId') + // Not seen exiting (an SSH restore before its terminal registers): listed pending under it. + expect(withMainSessionTabRows(published, projected, NONE_EXITED).tabs[0]).toMatchObject({ + ptyId: 'pty-gone' + }) + }) + + it('puts a window-only row of a group main lacks into the active group, not the first', () => { + const published: RuntimeMobileSessionTabsSnapshot = { + ...windowSnapshot([terminal('t::a'), terminal('u::a'), browser('b1')], ['t'], 'u::a'), + activeGroupId: 'g2', + tabGroups: [ + { id: 'g', activeTabId: 't', tabOrder: ['t'] }, + { id: 'g2', activeTabId: 'u', tabOrder: ['u'] }, + { id: 'g3', activeTabId: 'b1', tabOrder: ['b1'] } + ] + } + const projected: MainSessionTabRows = { + ...mainProjection([terminal('t::a'), terminal('u::a')], ['t'], 't::a'), + tabGroups: [ + { id: 'g', activeTabId: 't', tabOrder: ['t'] }, + { id: 'g2', activeTabId: 'u', tabOrder: ['u'] } + ] + } + + const merged = withMainSessionTabRows(published, projected, NONE_EXITED) + + expect(merged.tabGroups).toEqual([ + { id: 'g', activeTabId: 't', tabOrder: ['t'] }, + { id: 'g2', activeTabId: 'u', tabOrder: ['u', 'b1'] } + ]) + expect(merged).toMatchObject({ activeGroupId: 'g2', activeTabId: 'u::a' }) + }) + + it("keeps each group's shown tab as main has it while main's selection stands", () => { + const merged = withMainSessionTabRows( + windowSnapshot([terminal('t::a'), terminal('t::new'), file('f1')], ['t', 'f1'], 't::new'), + mainProjection([terminal('t::a'), file('f1')], ['t', 'f1'], 'f1'), + NONE_EXITED + ) + + expect(merged).toMatchObject({ activeTabId: 'f1', activeTabType: 'file' }) + expect(merged.tabGroups?.[0]?.activeTabId).toBe('f1') + expect(merged.tabs.filter((tab) => tab.isActive).map((tab) => tab.id)).toEqual(['f1']) + }) + + it("puts a stray row the window selects into main's active group", () => { + const published: RuntimeMobileSessionTabsSnapshot = { + ...windowSnapshot([terminal('t::a'), terminal('u::a'), browser('b1')], ['t'], 'b1'), + activeGroupId: 'g3', + tabGroups: [ + { id: 'g', activeTabId: 't', tabOrder: ['t'] }, + { id: 'g2', activeTabId: 'u', tabOrder: ['u'] }, + { id: 'g3', activeTabId: 'b1', tabOrder: ['b1'] } + ] + } + const projected: MainSessionTabRows = { + ...mainProjection([terminal('t::a'), terminal('u::a')], ['t'], 'u::a'), + activeGroupId: 'g2', + tabGroups: [ + { id: 'g', activeTabId: 't', tabOrder: ['t'] }, + { id: 'g2', activeTabId: 'u', tabOrder: ['u'] } + ] + } + + const merged = withMainSessionTabRows(published, projected, NONE_EXITED) + + expect(merged.tabGroups?.[1]).toEqual({ id: 'g2', activeTabId: 'b1', tabOrder: ['u', 'b1'] }) + expect(merged).toMatchObject({ activeGroupId: 'g2', activeTabId: 'b1' }) + }) + + it("takes a markdown edit view's dirty state from the window", () => { + const markdown = (isDirty: boolean) => ({ + type: 'markdown' as const, + id: 'm1', + title: 'README.md', + filePath: '/wt/README.md', + relativePath: 'README.md', + language: 'markdown' as const, + mode: 'edit' as const, + sourceFileId: '/wt/README.md', + sourceFilePath: '/wt/README.md', + sourceRelativePath: 'README.md', + documentVersion: 'file:/wt/README.md', + isDirty, + isActive: false + }) + const merged = withMainSessionTabRows( + windowSnapshot([markdown(true)], ['m1'], 'm1'), + mainProjection([markdown(false)], ['m1'], 'm1'), + NONE_EXITED + ) + + expect(merged.tabs[0]).toMatchObject({ id: 'm1', isDirty: true }) + }) +}) diff --git a/src/main/runtime/session-tabs/main-session-tab-rows.ts b/src/main/runtime/session-tabs/main-session-tab-rows.ts new file mode 100644 index 00000000000..35c6ff242df --- /dev/null +++ b/src/main/runtime/session-tabs/main-session-tab-rows.ts @@ -0,0 +1,227 @@ +// Layout-owner builds, before main holds browser and chat records (D3b): a workspace main owns +// publishes its terminal and editor rows, tab order and groups from main's layout +// (projectSessionTabs). The window's live publication supplies what the user sees: selection, the +// focused pane, dirty state and live terminal titles (main's copy of the view lags a debounced +// save), the rows main's model does not hold (browser, chat, diff and other view-only editor rows) +// and the facts only the window has (terminal theme, agent status, launch drafts, a markdown +// view's mode, file id and draft version). + +import type { RuntimeMobileSessionSnapshotTab } from '../../../shared/runtime-mobile-session-tab-contracts' +import type { + RuntimeMobileSessionTabGroup, + RuntimeMobileSessionTabsSnapshot +} from '../../../shared/runtime-session-contracts' + +type Row = RuntimeMobileSessionSnapshotTab + +/** Main's projection of the rows (projectSessionTabs): the snapshot minus the runtime's stamps. */ +export type MainSessionTabRows = Omit< + RuntimeMobileSessionTabsSnapshot, + 'publicationEpoch' | 'snapshotVersion' | 'worktreeInstanceId' | 'retiredTerminalSurfaces' +> + +/** + * Rows main's layout answers for: terminals and edit-mode files (the model holds no diff or + * preview-only tab). Any other row is the window's until D3b. + */ +function isMainRow(row: Row): boolean { + return ( + row.type === 'terminal' || + (row.type === 'file' && row.mode !== 'diff') || + (row.type === 'markdown' && row.mode === 'edit') + ) +} + +/** + * Main's row with the fields only the window knows, from the window's row of the same id. A + * window terminal id names a pane main has not bound yet (an SSH restore before its terminal + * registers), unless main has seen that terminal exit: then main's cleared binding wins. + */ +function withWindowFacts( + row: Row, + windowRow: Row | undefined, + exited: (ptyId: string) => boolean +): Row { + if (row.type === 'terminal' && windowRow?.type === 'terminal') { + return { + ...row, + // The live title (shell or agent) unless either side has a manual name. + ...(!row.customTitle && !windowRow.customTitle ? { title: windowRow.title } : {}), + ...(windowRow.terminalTheme ? { terminalTheme: windowRow.terminalTheme } : {}), + ...(windowRow.agentStatus !== undefined ? { agentStatus: windowRow.agentStatus } : {}), + ...(!row.ptyId && windowRow.ptyId && !exited(windowRow.ptyId) + ? { ptyId: windowRow.ptyId } + : {}), + ...(windowRow.launchDraft !== undefined ? { launchDraft: windowRow.launchDraft } : {}), + ...(windowRow.launchDraftCreatedAt !== undefined + ? { launchDraftCreatedAt: windowRow.launchDraftCreatedAt } + : {}) + } + } + if (row.type === 'markdown' && windowRow?.type === 'markdown') { + return { + ...row, + isDirty: windowRow.isDirty, + mode: windowRow.mode, + sourceFileId: windowRow.sourceFileId, + documentVersion: windowRow.documentVersion + } + } + if (row.type === 'file' && windowRow?.type === 'file') { + return { ...row, isDirty: windowRow.isDirty } + } + return row +} + +/** The id a group's tab order lists a row under: a terminal pane's tab, else the row itself. */ +function orderIdOf(row: Row): string { + return row.type === 'terminal' ? row.parentTabId : row.id +} + +/** Each window-only id placed after the id it follows in the window's group, else at the end. */ +function withWindowOnlyIds( + tabOrder: readonly string[], + windowOrder: readonly string[], + windowOnly: ReadonlySet +): string[] { + const order = [...tabOrder] + windowOrder.forEach((id, index) => { + if (!windowOnly.has(id) || order.includes(id)) { + return + } + const after = windowOrder + .slice(0, index) + .toReversed() + .find((previous) => order.includes(previous)) + order.splice(after === undefined ? 0 : order.indexOf(after) + 1, 0, id) + }) + return order +} + +/** Main's rows in main's order, each window-only row after the row it follows in the window's. */ +function withWindowOnlyRows( + rows: Row[], + windowRows: readonly Row[], + windowOnly: ReadonlySet +): Row[] { + const merged = [...rows] + windowRows.forEach((row, index) => { + if (!windowOnly.has(row.id)) { + return + } + const after = windowRows + .slice(0, index) + .toReversed() + .find((previous) => merged.some((entry) => entry.id === previous.id)) + const at = after ? merged.findIndex((entry) => entry.id === after.id) + 1 : 0 + merged.splice(at, 0, row) + }) + return merged +} + +/** + * The window's live snapshot of a workspace main owns, with main's terminal and editor rows, tab + * order and groups. The window keeps its publication stamps (so the snapshot fences hold as today) + * and its view: what it selects stands whenever main lists that row. + */ +export function withMainSessionTabRows( + published: RuntimeMobileSessionTabsSnapshot, + projected: MainSessionTabRows, + exited: (ptyId: string) => boolean +): RuntimeMobileSessionTabsSnapshot { + const windowRows = new Map(published.tabs.map((row) => [row.id, row])) + const projectedIds = new Set(projected.tabs.map((row) => row.id)) + const windowOnly = published.tabs.filter((row) => !projectedIds.has(row.id) && !isMainRow(row)) + // A main-held row is main's; a browser or chat row stays the window's while both list it. + const rows = projected.tabs.map((row) => + isMainRow(row) + ? withWindowFacts(row, windowRows.get(row.id), exited) + : (windowRows.get(row.id) ?? row) + ) + const windowOnlyIds = new Set(windowOnly.map((row) => row.id)) + const merged = withWindowOnlyRows(rows, published.tabs, windowOnlyIds) + const listed = new Map(merged.map((row) => [row.id, row])) + const windowActive = published.activeTabId ? listed.get(published.activeTabId) : undefined + const windowGroups = new Map((published.tabGroups ?? []).map((group) => [group.id, group])) + const tabGroups: RuntimeMobileSessionTabGroup[] = (projected.tabGroups ?? []).map((group) => { + const tabOrder = withWindowOnlyIds( + group.tabOrder, + windowGroups.get(group.id)?.tabOrder ?? [], + windowOnlyIds + ) + // The tab the window shows in this group, when main's group still holds it; while main's + // selection stands (the window selects a row main lacks), main's groups stay as main has them. + const shown = windowActive ? windowGroups.get(group.id)?.activeTabId : undefined + return { + ...group, + tabOrder, + ...(shown && tabOrder.includes(shown) ? { activeTabId: shown } : {}) + } + }) + const activeGroupIndex = (activeRow: Row | undefined): number => { + const byRow = activeRow + ? tabGroups.findIndex((group) => group.tabOrder.includes(orderIdOf(activeRow))) + : -1 + if (byRow !== -1) { + return byRow + } + const byWindow = windowActive + ? tabGroups.findIndex((group) => group.id === published.activeGroupId) + : -1 + return byWindow !== -1 + ? byWindow + : tabGroups.findIndex((group) => group.id === projected.activeGroupId) + } + const placed = new Set(tabGroups.flatMap((group) => group.tabOrder)) + // A window-only row in a group main does not hold joins the active group: groups and their + // layout are main's, so the phone gets no group the layout lacks. + const stray = (published.tabGroups ?? []).flatMap((group) => + group.tabOrder.filter((id) => windowOnlyIds.has(id) && !placed.has(id)) + ) + const strayInto = Math.max(activeGroupIndex(windowActive), 0) + const strayGroup = tabGroups[strayInto] + if (stray.length > 0 && strayGroup) { + tabGroups[strayInto] = { ...strayGroup, tabOrder: [...strayGroup.tabOrder, ...stray] } + } else if (stray.length > 0) { + // Main lists no group: the window's groups hold the rows only it knows. + for (const group of published.tabGroups ?? []) { + const own = group.tabOrder.filter((id) => windowOnlyIds.has(id)) + if (own.length > 0) { + tabGroups.push({ ...group, tabOrder: own }) + } + } + } + // The window's selection is the user's view; main's stands only while the window selects a row + // main does not list (a pane drawn before main's frame, or one main closed). + const index = activeGroupIndex(windowActive) + const activeGroup = index === -1 ? undefined : tabGroups[index] + if (windowActive && activeGroup) { + tabGroups[index] = { ...activeGroup, activeTabId: orderIdOf(windowActive) } + } + const selection = windowActive + ? { + activeGroupId: activeGroup?.id ?? projected.activeGroupId, + activeTabId: windowActive.id, + activeTabType: windowActive.type + } + : { + activeGroupId: projected.activeGroupId, + activeTabId: projected.activeTabId, + activeTabType: projected.activeTabType + } + const activeId = selection.activeTabId + const { tabGroups: _windowGroups, tabGroupLayout: _windowLayout, ...stamps } = published + return { + ...stamps, + ...selection, + ...(tabGroups.length > 0 ? { tabGroups } : {}), + ...(projected.tabGroupLayout + ? { tabGroupLayout: projected.tabGroupLayout } + : tabGroups.length > 0 && published.tabGroupLayout + ? { tabGroupLayout: published.tabGroupLayout } + : {}), + tabs: merged.map((row) => + row.isActive === (row.id === activeId) ? row : { ...row, isActive: row.id === activeId } + ) + } +} 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 new file mode 100644 index 00000000000..0cc3f59e83c --- /dev/null +++ b/src/main/runtime/session-tabs/session-tabs-headless-main-change.test.ts @@ -0,0 +1,44 @@ +// A windowless server (orca serve, orcad): a change main makes to its own layout reaches a +// subscribed phone, with no window to republish rows. Rows there are rebuilt from the saved layout +// main writes (mobile-session-hydration.ts), not by A2's merge, which needs a window publication. +import { describe, expect, it, vi } from 'vitest' +import { HEADLESS_RUNTIME_WINDOW_ID } from '../../../shared/runtime-types' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts' +import { TEST_WORKTREE_ID } from '../orca-runtime-test-fixtures.spec' +import { + SURFACE_TAB_ID, + surfaceRuntime +} from '../terminal/surface/terminal-surface-replies.test-fixture' + +vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +describe('session.tabs on a windowless server (switch on)', () => { + it("a tab main renames reaches a subscribed phone's rows", async () => { + vi.useFakeTimers() + try { + const { runtime } = surfaceRuntime() + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const seen: RuntimeMobileSessionTabsResult[] = [] + runtime.onMobileSessionTabsChanged((snapshot) => { + seen.push(snapshot) + }) + + await runtime.applyLayoutCommand({ + type: 'renameTab', + workspace: TEST_WORKTREE_ID, + tabId: SURFACE_TAB_ID, + kind: 'custom', + title: 'Build' + }) + await vi.runAllTimersAsync() + + // Pushed by main on its own, before any phone asks again. + expect(seen.at(-1)?.tabs.map((tab) => tab.title)).toEqual(['Build', 'Build']) + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect(listed.tabs.map((tab) => tab.title)).toEqual(['Build', 'Build']) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/session-tabs/session-tabs-inventory.ts b/src/main/runtime/session-tabs/session-tabs-inventory.ts index f866e1d16d1..8853f09bb0a 100644 --- a/src/main/runtime/session-tabs/session-tabs-inventory.ts +++ b/src/main/runtime/session-tabs/session-tabs-inventory.ts @@ -35,6 +35,8 @@ export abstract class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees e reads: () => this.workspaceLayoutReads, homeHostId: (key) => this.tryGetWorkspaceSessionHostIdForWorktree(key), registered: (ptyId) => this.ptysById.has(ptyId), + // Certified exit only: a dropped relay leaves records disconnected but unverifiable. + exited: (ptyId) => this.ptyLivenessVerdictByPtyId.get(ptyId)?.verdict.status === 'exited', paneStartFact: (paneKey, binding) => this.paneStartStatuses?.get(paneKey, binding) ?? null, readyGraphPublisher: () => (this.graphStatus === 'ready' ? this.authoritativeWindowId : null), publish: (publisher, next) => { 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 new file mode 100644 index 00000000000..c7b0d38194f --- /dev/null +++ b/src/main/runtime/session-tabs/session-tabs-main-rows-parity.test.ts @@ -0,0 +1,153 @@ +// Phone-mirror parity (flip step 7) against main's projection: with the switch on, the rows a +// phone or paired desktop reads for a workspace main owns follow main's layout after a create, a +// close and a rename, whatever rows the window's next publication still carries. Main's rows land +// with that publication (the window republishes after each layout frame), not before it. +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-session-contracts' +import { HEADLESS_RUNTIME_WINDOW_ID } from '../../../shared/runtime-types' +import { + HEADLESS_LEAF_ID, + HEADLESS_SECOND_LEAF_ID, + HEADLESS_THIRD_LEAF_ID, + TEST_WORKTREE_ID +} from '../orca-runtime-test-fixtures.spec' +import { + SURFACE_TAB_ID, + surfaceRuntime +} from '../terminal/surface/terminal-surface-replies.test-fixture' + +vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +const row = (leafId: string) => `${SURFACE_TAB_ID}::${leafId}` + +/** The window's publication as it was before main's change: both panes, the old title. */ +function stalePublication( + version: number, + activeLeafId: string = HEADLESS_LEAF_ID +): RuntimeMobileSessionTabsSnapshot { + const tabs = [HEADLESS_LEAF_ID, HEADLESS_SECOND_LEAF_ID].map((leafId) => ({ + type: 'terminal' as const, + id: row(leafId), + title: 'Persisted Terminal', + parentTabId: SURFACE_TAB_ID, + leafId, + isActive: leafId === activeLeafId + })) + return { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer', + snapshotVersion: version, + activeGroupId: null, + activeTabId: row(activeLeafId), + activeTabType: 'terminal', + tabs + } +} + +async function phoneActiveRow(runtime: ReturnType['runtime']) { + return (await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).activeTabId +} + +async function phoneRows(runtime: ReturnType['runtime']) { + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + return listed.tabs.map((tab) => ({ + id: tab.id, + title: tab.title, + customTitle: tab.type === 'terminal' ? tab.customTitle : undefined + })) +} + +describe('session.tabs rows follow main’s layout (switch on)', () => { + it('a pane main split in, and a pane main closed, before the window republished', async () => { + const { runtime, store, setSession } = surfaceRuntime() + runtime.attachWindow(1) + const session = store.getSession() + const layout = session.terminalLayoutsByTabId[SURFACE_TAB_ID]! + setSession({ + ...session, + terminalLayoutsByTabId: { + [SURFACE_TAB_ID]: { + ...layout, + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: HEADLESS_LEAF_ID }, + second: { type: 'leaf', leafId: HEADLESS_THIRD_LEAF_ID } + } + } + } + }) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [stalePublication(1)] }) + + const rows = await phoneRows(runtime) + expect(rows.map((entry) => entry.id)).toEqual([ + row(HEADLESS_LEAF_ID), + row(HEADLESS_THIRD_LEAF_ID) + ]) + }) + + it('a tab main renamed before the window republished', async () => { + const { runtime } = surfaceRuntime() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [stalePublication(1)] }) + await runtime.applyLayoutCommand({ + type: 'renameTab', + workspace: TEST_WORKTREE_ID, + tabId: SURFACE_TAB_ID, + kind: 'custom', + title: 'Build' + }) + runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [stalePublication(2)] }) + + const rows = await phoneRows(runtime) + expect(rows.map((entry) => [entry.title, entry.customTitle])).toEqual([ + ['Build', 'Build'], + ['Build', 'Build'] + ]) + }) + + it("save or view lands after publication: the phone's row updates", async () => { + const { runtime, store, setSession } = surfaceRuntime() + runtime.attachWindow(1) + // The user focuses the second pane: the window publishes at once, its save lands later. + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [stalePublication(1, HEADLESS_SECOND_LEAF_ID)] + }) + expect(await phoneActiveRow(runtime)).toBe(row(HEADLESS_SECOND_LEAF_ID)) + + const session = store.getSession() + const layout = session.terminalLayoutsByTabId[SURFACE_TAB_ID]! + setSession({ + ...session, + terminalLayoutsByTabId: { + [SURFACE_TAB_ID]: { ...layout, activeLeafId: HEADLESS_SECOND_LEAF_ID } + } + }) + expect(await phoneActiveRow(runtime)).toBe(row(HEADLESS_SECOND_LEAF_ID)) + + // Back to the first pane, published before its save: the phone follows the view. + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [stalePublication(2, HEADLESS_LEAF_ID)] + }) + expect(await phoneActiveRow(runtime)).toBe(row(HEADLESS_LEAF_ID)) + }) + + it("with no window, rows follow main's saved view", async () => { + const { runtime, store, setSession } = surfaceRuntime() + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + const session = store.getSession() + const layout = session.terminalLayoutsByTabId[SURFACE_TAB_ID]! + setSession({ + ...session, + terminalLayoutsByTabId: { + [SURFACE_TAB_ID]: { ...layout, activeLeafId: HEADLESS_SECOND_LEAF_ID } + } + }) + + expect(await phoneActiveRow(runtime)).toBe(row(HEADLESS_SECOND_LEAF_ID)) + }) +}) diff --git a/src/main/runtime/terminal/surface/main-terminal-surfaces.ts b/src/main/runtime/terminal/surface/main-terminal-surfaces.ts index b80bfd31166..eeb2c130d5b 100644 --- a/src/main/runtime/terminal/surface/main-terminal-surfaces.ts +++ b/src/main/runtime/terminal/surface/main-terminal-surfaces.ts @@ -7,6 +7,8 @@ import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id import type { MainPaneBinding } from './main-pane-verdict' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { RuntimeSyncWindowGraph } from '../../../../shared/runtime-types' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-session-contracts' +import { withMainSessionTabRows } from '../../session-tabs/main-session-tab-rows' import type { RuntimeStore } from '../../runtime-store-contract' import type { WorkspaceLayoutReads } from '../../workspace-layout-stream' import { @@ -22,6 +24,8 @@ export type MainTerminalSurfacesHost = { homeHostId: (key: string) => ExecutionHostId | null /** Whether the PTY registry holds the terminal. */ registered: (ptyId: string) => boolean + /** Whether the host certified the terminal exited (not merely disconnected or unverifiable). */ + exited: (ptyId: string) => boolean /** Main's start status of a pane, as the layout stream publishes it. */ paneStartFact: PaneStartFactOf /** The graph authority now, when its graph is ready; null otherwise. */ @@ -54,7 +58,23 @@ class MainSurfacePublisher { this.index.start() this.lastWindowGraph = { windowId, graph } this.installed = this.index.surfaces() - return composeMainTerminalSurfaces(graph, this.installed, this.host.registered) + const composed = composeMainTerminalSurfaces(graph, this.installed, this.host.registered) + return graph.mobileSessionTabs + ? { + ...composed, + mobileSessionTabs: graph.mobileSessionTabs.map((rows) => this.withMainRows(rows)) + } + : composed + } + + /** A workspace main owns publishes main's terminal and editor rows; any other, the window's. */ + private withMainRows( + published: RuntimeMobileSessionTabsSnapshot + ): RuntimeMobileSessionTabsSnapshot { + const projected = this.installed?.worktreeIds.has(published.worktree) + ? this.index.sessionTabsOf(published.worktree) + : null + return projected ? withMainSessionTabRows(published, projected, this.host.exited) : published } /** The pane main's layout binds a terminal to, read fresh; null when no pane main owns holds it. */ diff --git a/src/main/runtime/terminal/surface/terminal-surface-index.ts b/src/main/runtime/terminal/surface/terminal-surface-index.ts index b118d9182bf..7a87b778432 100644 --- a/src/main/runtime/terminal/surface/terminal-surface-index.ts +++ b/src/main/runtime/terminal/surface/terminal-surface-index.ts @@ -11,6 +11,10 @@ import type { import type { LoadedWorkspaceLayout } from '../../../../shared/workspace-layout/workspace-layout-load-types' import { collectLayoutLeafIdsInOrder } from '../../../../shared/workspace-layout/terminal-pane-tree' import { bindingOf } from '../../pane-start-statuses' +import { + projectSessionTabs, + type ProjectedSessionTabs +} from '../../../../shared/workspace-layout/old-client/old-client-session-tabs-projection' import { projectRuntimeSurfaces } from '../../runtime-leaf-record-projection' import type { WorkspaceLayoutReads } from '../../workspace-layout-stream' @@ -175,6 +179,29 @@ export class TerminalSurfaceIndex { } } + /** A workspace main owns as today's `session.tabs` rows, from its owning partition's layout. */ + sessionTabsOf(worktreeId: string): ProjectedSessionTabs | null { + this.surfaces() + const hostIds = this.deps + .hostIds() + .filter((hostId) => parseExecutionHostId(hostId)?.kind !== 'runtime') + for (const [index, partition] of this.projectedFrom.entries()) { + const hostId = hostIds[index] + for (const [key, workspace] of Object.entries(partition?.layout.workspaces ?? {})) { + if (partition && workspace.worktreeId === worktreeId && this.routes.get(key) === hostId) { + return projectSessionTabs( + key, + workspace, + partition.desktopView, + partition.facts, + this.deps.generatedTitlesEnabled() + ) + } + } + } + return null + } + private homeOf(key: string): ExecutionHostId | null { try { return this.deps.homeHostId(key) diff --git a/src/shared/workspace-layout/old-client/old-client-group-order.ts b/src/shared/workspace-layout/old-client/old-client-group-order.ts new file mode 100644 index 00000000000..dd3e97e83ce --- /dev/null +++ b/src/shared/workspace-layout/old-client/old-client-group-order.ts @@ -0,0 +1,18 @@ +import type { WorkspaceLayout } from '../workspace-layout-model' +import type { TabGroupLayoutNode } from '../../tab-types' + +/** A workspace's groups in its group layout's order; groups the layout misses go last. */ +export function groupsInLayoutOrder(workspace: WorkspaceLayout): WorkspaceLayout['groups'] { + const ids: string[] = [] + const visit = (node: TabGroupLayoutNode | undefined): void => { + if (node?.type === 'leaf') { + ids.push(node.groupId) + } else if (node) { + visit(node.first) + visit(node.second) + } + } + visit(workspace.groupLayout) + const placed = ids.flatMap((id) => workspace.groups.filter((group) => group.id === id)) + return [...placed, ...workspace.groups.filter((group) => !placed.includes(group))] +} diff --git a/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.test.ts b/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.test.ts index b10cb48be03..dca0ef3221f 100644 --- a/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.test.ts +++ b/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.test.ts @@ -175,6 +175,7 @@ describe('projectSessionTabs', () => { type: 'terminal', id: `${apiTab}::${api!.leafId!}`, title: 'logs', + customTitle: 'api server', parentTabId: apiTab, leafId: api!.leafId!, ptyId: 'pty-1', @@ -188,6 +189,7 @@ describe('projectSessionTabs', () => { type: 'terminal', id: `${apiTab}::${apiSplit}`, title: 'Terminal', + customTitle: 'api server', parentTabId: apiTab, leafId: apiSplit, ptyId: 'pty-2', @@ -200,6 +202,7 @@ describe('projectSessionTabs', () => { type: 'terminal', id: `${agent!.tabId!}::${agent!.leafId!}`, title: 'Claude Code', + customTitle: null, parentTabId: agent!.tabId!, leafId: agent!.leafId!, launchAgent: 'claude', diff --git a/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts b/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts index 3724292589a..923e04bc9de 100644 --- a/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts +++ b/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts @@ -18,7 +18,6 @@ import type { RuntimeMobileSessionTabGroup, RuntimeMobileSessionTabsSnapshot } from '../../runtime-session-contracts' -import type { TabGroupLayoutNode } from '../../tab-types' import type { TerminalLayoutSnapshot } from '../../terminal-tab-types' import { pruneGroupLayout } from '../../workspace-session-terminal-tab-close' import { @@ -27,6 +26,7 @@ import { terminalRowId } from './old-client-session-tab-rows' import { collectLayoutLeafIdsInOrder } from '../terminal-pane-tree' +import { groupsInLayoutOrder } from './old-client-group-order' import { BLANK_BROWSER_TAB_STATE, type DesktopLayoutView, @@ -143,21 +143,6 @@ function resolveActiveGroupId( return groupsInLayoutOrder(workspace)[0]?.id ?? null } -function groupsInLayoutOrder(workspace: WorkspaceLayout): WorkspaceLayout['groups'] { - const ids: string[] = [] - const visit = (node: TabGroupLayoutNode | undefined): void => { - if (node?.type === 'leaf') { - ids.push(node.groupId) - } else if (node) { - visit(node.first) - visit(node.second) - } - } - visit(workspace.groupLayout) - const placed = ids.flatMap((id) => workspace.groups.filter((group) => group.id === id)) - return [...placed, ...workspace.groups.filter((group) => !placed.includes(group))] -} - function projectTab(tab: LayoutTab, scope: Scope): RuntimeMobileSessionSnapshotTab[] { switch (tab.kind) { case 'terminal': @@ -225,6 +210,8 @@ function terminalRows(tab: LayoutTerminalTab, scope: Scope): RuntimeMobileSessio type: 'terminal', id: terminalRowId(tab.entityId, leafId), title, + // A paired desktop reads an absent name as "keep mine", so a cleared name is sent as null. + customTitle: tab.customTitle?.trim() || null, ...(tabWide && tab.quickCommandLabel?.trim() ? { quickCommandLabel: tab.quickCommandLabel.trim() } : {}),