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