diff --git a/src/main/runtime/mobile-session-terminal-projection.ts b/src/main/runtime/mobile-session-terminal-projection.ts index c99ac2e86a5..8743dd17158 100644 --- a/src/main/runtime/mobile-session-terminal-projection.ts +++ b/src/main/runtime/mobile-session-terminal-projection.ts @@ -1,6 +1,10 @@ import type { RuntimeMobileSessionTerminalTab } from '../../shared/runtime-types' import type { TerminalTab } from '../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { + readCanonicalPtyOwners, + resolveCanonicalPtyClaim +} from '../../shared/workspace-layout/legacy-terminal-row-claims' import { cloneTerminalLayoutSnapshot, collectPersistedTerminalLeafIds, @@ -8,12 +12,42 @@ import { isPersistedTerminalLeafActive } from './mobile-session-layout-projection' +/** + * Drops a legacy row with no tab-bar entry whose every terminal a tab-bar tab of this workspace + * holds (the shared Loader's rule; the window hides it too), and moves a selection on it to that tab. + */ +function withoutAbsorbedLegacyRows( + worktreeId: string, + persistedTabs: readonly TerminalTab[], + session: WorkspaceSessionState +): { rows: TerminalTab[]; selection: WorkspaceSessionState } { + const canonical = readCanonicalPtyOwners(session, worktreeId, persistedTabs) + const canonicalTabIdByAbsorbedTabId = new Map() + const rows = persistedTabs.filter((row) => { + const claim = resolveCanonicalPtyClaim(session, row, canonical) + if (claim.kind === 'subsumed') { + canonicalTabIdByAbsorbedTabId.set(row.id, claim.canonicalTabId) + } + return claim.kind !== 'subsumed' + }) + const activeTabId = session.activeTabIdByWorktree?.[worktreeId] ?? session.activeTabId + const followed = activeTabId ? canonicalTabIdByAbsorbedTabId.get(activeTabId) : undefined + const selection = followed + ? { + ...session, + activeTabIdByWorktree: { ...session.activeTabIdByWorktree, [worktreeId]: followed } + } + : session + return { rows, selection } +} + export function buildHeadlessMobileSessionTerminalTabs( worktreeId: string, persistedTabs: readonly TerminalTab[], session: WorkspaceSessionState ): RuntimeMobileSessionTerminalTab[] { - return [...persistedTabs] + const { rows, selection } = withoutAbsorbedLegacyRows(worktreeId, persistedTabs, session) + return rows .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) .flatMap((tab, index) => { const layout = session.terminalLayoutsByTabId?.[tab.id] @@ -43,7 +77,7 @@ export function buildHeadlessMobileSessionTerminalTabs( ...(tab.color != null ? { color: tab.color } : {}), ...(tab.isPinned ? { isPinned: true } : {}), ...(tab.viewMode ? { viewMode: tab.viewMode } : {}), - isActive: isPersistedTerminalLeafActive(session, worktreeId, tab.id, leafId, layout) + isActive: isPersistedTerminalLeafActive(selection, worktreeId, tab.id, leafId, layout) } ] }) diff --git a/src/main/runtime/session-tabs/headless-mobile-session-absorbed-rows.test.ts b/src/main/runtime/session-tabs/headless-mobile-session-absorbed-rows.test.ts new file mode 100644 index 00000000000..cac2676bc46 --- /dev/null +++ b/src/main/runtime/session-tabs/headless-mobile-session-absorbed-rows.test.ts @@ -0,0 +1,90 @@ +// A phone on a host with no window may show something other than main only where it then shows +// what the desktop window does; the window's side is pinned in headless-phone-window-parity.test.ts. + +import { describe, expect, it } from 'vitest' +import { getDefaultSettings } from '../../../shared/constants' +import { + HEADLESS_PHONE_LAYOUT_CASES, + HEADLESS_PHONE_WORKTREES, + type PhoneLayoutView +} from '../../../shared/workspace-layout/headless-phone-layout.test-fixture' +import { leaf } from '../../../shared/workspace-layout/workspace-layout-session.test-fixture' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { deriveHeadlessLegacyTerminalLeafId } from '../mobile-session-layout-projection' +import { OrcaRuntimeService } from '../orca-runtime' + +const repos = [ + { id: 'repo-1', path: '/repos', displayName: 'repo-1', badgeColor: 'blue', addedAt: 1 } +] + +function hydratedRuntime(session: WorkspaceSessionState) { + const runtime = new OrcaRuntimeService({ + getRepo: (id: string) => repos.find((repo) => repo.id === id), + getRepos: () => repos, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + addRepo: () => {}, + updateRepo: () => { + throw new Error('unused') + }, + setWorktreeMeta: () => { + throw new Error('unused') + }, + removeWorktreeMeta: () => {}, + getGitHubCache: () => ({ pr: {}, issue: {} }), + getRetiredWorktreeNameRegistry: () => ({ exhaustedTiers: 0, names: [] }), + addRetiredWorktreeName: () => {}, + mergeRetiredWorktreeNames: () => false, + getSettings: () => getDefaultSettings('/tmp/home'), + getWorkspaceSession: () => session, + setWorkspaceSession: () => {} + }) + runtime.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(undefined, { force: true }) + return runtime +} + +function phoneView(session: WorkspaceSessionState): PhoneLayoutView { + const view: PhoneLayoutView = {} + for (const [worktreeId, snapshot] of hydratedRuntime(session).mobileSessionTabsByWorktree) { + view[worktreeId] = { + panes: snapshot.tabs.flatMap((tab) => + tab.type === 'terminal' ? [`${tab.parentTabId} ${tab.ptyId ?? '-'} ${tab.title}`] : [] + ), + active: snapshot.activeTabId?.split('::')[0] ?? null, + groups: (snapshot.tabGroups ?? []).map((group) => ({ + tabs: group.tabOrder, + active: group.activeTabId + })) + } + } + return view +} + +describe('headless phone terminal tabs', () => { + it.each(HEADLESS_PHONE_LAYOUT_CASES)('$name', (scenario) => { + for (const worktreeId of scenario.phoneFollowsDesktop) { + expect(scenario.desktop[worktreeId]).not.toEqual(scenario.main[worktreeId]) + } + const expected = Object.fromEntries( + Object.entries(scenario.main).map(([worktreeId, view]) => [ + worktreeId, + scenario.phoneFollowsDesktop.includes(worktreeId) ? scenario.desktop[worktreeId] : view + ]) + ) + expect(phoneView(scenario.build())).toEqual(expected) + }) + + it('keeps pane ids in worktrees with no absorbed row', () => { + const scenario = HEADLESS_PHONE_LAYOUT_CASES.find((entry) => + entry.name.startsWith('duplicate in one worktree') + )! + const { B, C, D } = HEADLESS_PHONE_WORKTREES + const leafIds = (worktreeId: string) => + hydratedRuntime(scenario.build()) + .mobileSessionTabsByWorktree.get(worktreeId) + ?.tabs.flatMap((tab) => (tab.type === 'terminal' ? [tab.leafId] : [])) + expect(leafIds(B)).toEqual([leaf(4), leaf(5)]) + expect(leafIds(C)).toEqual([leaf(6)]) + expect(leafIds(D)).toEqual([deriveHeadlessLegacyTerminalLeafId('tab-old')]) + }) +}) diff --git a/src/renderer/src/app-shell/headless-phone-window-parity.test.ts b/src/renderer/src/app-shell/headless-phone-window-parity.test.ts new file mode 100644 index 00000000000..d4b92f88108 --- /dev/null +++ b/src/renderer/src/app-shell/headless-phone-window-parity.test.ts @@ -0,0 +1,79 @@ +// What the desktop window shows at startup for the partitions a phone on a host with no window is +// compared against in headless-mobile-session-absorbed-rows.test.ts. + +import { expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { + HEADLESS_PHONE_LAYOUT_CASES, + type PhoneLayoutView +} from '../../../shared/workspace-layout/headless-phone-layout.test-fixture' +import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness' +import { createTestStore } from '../store/slices/store-test-helpers' +import { + bootWindowFromPartitions, + projectStartupWindowView +} from './startup-window-view.test-fixture' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +createStoreSessionMockApi() + +/** Terminals of a projected pane tree, in order; see `projectStartupWindowView`. */ +function paneTerminals(node: unknown): (string | null)[] { + if (typeof node !== 'object' || node === null) { + return [] + } + if ('pane' in node) { + return [typeof node.pane === 'string' ? node.pane : null] + } + return [ + ...('first' in node ? paneTerminals(node.first) : []), + ...('second' in node ? paneTerminals(node.second) : []) + ] +} + +function windowView(view: Record, worktreeIds: string[]): PhoneLayoutView { + const result: PhoneLayoutView = {} + for (const worktreeId of worktreeIds) { + const tabIds = Object.keys(view) + .filter((key) => key.startsWith(`${worktreeId} group `) && key.endsWith(' tabs')) + .flatMap((key) => (Array.isArray(view[key]) ? view[key].map(String) : [])) + const panes = tabIds.flatMap((tabId) => { + const terminal = `${worktreeId} terminal ${tabId}` + if (!(`${terminal} panes` in view)) { + return [] + } + const title = String(view[`${terminal} phone and CLI title`]) + const terminals = paneTerminals(view[`${terminal} panes`]) + return terminals.length === 0 + ? [`${tabId} (no layout yet) ${title}`] + : terminals.map((ptyId) => `${tabId} ${ptyId ?? '-'} ${title}`) + }) + const active = view[`${worktreeId} active terminal`] + const groups = Object.keys(view) + .filter((key) => key.startsWith(`${worktreeId} group `) && key.endsWith(' tabs')) + .map((key) => { + const groupActive = view[key.replace(/ tabs$/, ' active tab')] + return { + tabs: Array.isArray(view[key]) ? view[key].map(String) : [], + active: typeof groupActive === 'string' ? groupActive : null + } + }) + result[worktreeId] = { panes, active: typeof active === 'string' ? active : null, groups } + } + return result +} + +it.each(HEADLESS_PHONE_LAYOUT_CASES)('window at startup: $name', async (scenario) => { + const state = await bootWindowFromPartitions(createTestStore(), [ + { hostId: LOCAL_EXECUTION_HOST_ID, session: scenario.build() } + ]) + expect(windowView(projectStartupWindowView(state), Object.keys(scenario.desktop))).toEqual( + scenario.desktop + ) +}) diff --git a/src/shared/workspace-layout/headless-phone-layout.test-fixture.ts b/src/shared/workspace-layout/headless-phone-layout.test-fixture.ts new file mode 100644 index 00000000000..2d6d520a7db --- /dev/null +++ b/src/shared/workspace-layout/headless-phone-layout.test-fixture.ts @@ -0,0 +1,281 @@ +// Stored partitions for comparing what a phone is sent from a host with no window against what +// the desktop window shows, one worktree per case so a fix in one cannot leak into another. + +import type { WorkspaceSessionState } from '../workspace-session-state-types' +import { LEGACY_WORKSPACE_LAYOUT_PROFILES } from './workspace-layout-legacy-profile.test-fixture' +import { addWorkspace, emptySession, GIT_KEY, leaf } from './workspace-layout-session.test-fixture' + +const A = 'repo-1::/repos/a' +const B = 'repo-1::/repos/b' +const C = 'repo-1::/repos/c' +const D = 'repo-1::/repos/d' + +/** Per worktree: each terminal pane in order as `tab terminal title`, the selected tab, and each + * tab group's tabs and selected tab. */ +export type PhoneLayoutView = Record< + string, + { panes: string[]; active: string | null; groups: { tabs: string[]; active: string | null }[] } +> + +export type HeadlessPhoneLayoutCase = { + name: string + build: () => WorkspaceSessionState + /** What main sends a phone from a host with no window. */ + main: PhoneLayoutView + /** What the desktop window shows at startup. */ + desktop: PhoneLayoutView + /** Worktrees where the phone now shows the desktop's view; it shows main's everywhere else. */ + phoneFollowsDesktop: string[] +} + +function legacyFixture(name: string): WorkspaceSessionState { + return LEGACY_WORKSPACE_LAYOUT_PROFILES.find((profile) => profile.name === name)!.build() +} + +/** A row saved before pane layouts existed, in its own worktree. */ +function withLayoutlessRow(session: WorkspaceSessionState): WorkspaceSessionState { + session.tabsByWorktree[D] = [ + { + id: 'tab-old', + ptyId: 'pty-old', + worktreeId: D, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + return session +} + +function contaminationSession(): WorkspaceSessionState { + let session = emptySession() + session = addWorkspace(session, A, [ + { + id: 'ga', + tabs: [ + { id: 'tab-a', leaves: [[leaf(1), 'pty-a']] }, + { id: 'tab-b', leaves: [[leaf(2), 'pty-b']] } + ] + } + ]) + session.tabsByWorktree[A]!.push({ + ...session.tabsByWorktree[A]![0]!, + id: 'tab-legacy', + title: 'Terminal 3', + sortOrder: 2, + createdAt: 1_700_000_009_000 + }) + session.terminalLayoutsByTabId['tab-legacy'] = { + root: { type: 'leaf', leafId: leaf(3) }, + activeLeafId: leaf(3), + expandedLeafId: null, + ptyIdsByLeafId: { [leaf(3)]: 'pty-a' } + } + session = addWorkspace(session, B, [ + { + id: 'gb', + tabs: [ + { id: 'tab-c', leaves: [[leaf(4), 'pty-c']] }, + { id: 'tab-d', leaves: [[leaf(5), 'pty-d']] } + ] + } + ]) + session.terminalSurfaceTombstonesByPaneKey = { + [`tab-d:${leaf(5)}`]: { + worktreeId: B, + parentTabId: 'tab-d', + leafId: leaf(5), + ptyId: 'pty-d', + incarnationId: 'incarnation-d', + retiredAt: 1 + } + } + session = addWorkspace(session, C, [ + { id: 'gc', tabs: [{ id: 'tab-e', leaves: [[leaf(6), 'pty-e']] }] } + ]) + session.unifiedTabs![C]![0]!.label = 'vim' + return withLayoutlessRow(session) +} + +function staleRowTerminalSession(): WorkspaceSessionState { + let session = addWorkspace(emptySession(), A, [ + { + id: 'ga', + tabs: [ + { id: 'tab-a', leaves: [[leaf(1), 'pty-a']] }, + { id: 'tab-b', leaves: [[leaf(2), 'pty-b']] } + ] + } + ]) + session.tabsByWorktree[A]![0]!.ptyId = 'pty-b' + session = addWorkspace(session, C, [ + { id: 'gc', tabs: [{ id: 'tab-e', leaves: [[leaf(6), 'pty-e']] }] } + ]) + session.unifiedTabs![C]![0]!.label = 'vim' + return withLayoutlessRow(session) +} + +function terminalInTwoWorktreesSession(): WorkspaceSessionState { + let session = addWorkspace(emptySession(), A, [ + { id: 'ga', tabs: [{ id: 'tab-a', leaves: [[leaf(1), 'pty-shared']] }] } + ]) + session = addWorkspace(session, B, [ + { id: 'gb', tabs: [{ id: 'tab-b', leaves: [[leaf(2), 'pty-shared']] }] } + ]) + return session +} + +function sshRelayDuplicateSession(): WorkspaceSessionState { + const session = legacyFixture('legacy row repeating a tab-bar tab’s terminal') + return JSON.parse(JSON.stringify(session).replaceAll('"pty-a"', '"ssh:target-1@@pty-1"')) +} + +const oneGroup = (tabs: string[], active: string) => [{ tabs, active }] +const duplicate = { + main: { + panes: ['tab-a pty-a Terminal 1', 'tab-b pty-b Terminal 2', 'tab-legacy pty-a Terminal 3'], + active: 'tab-a', + groups: oneGroup(['tab-a', 'tab-b', 'tab-legacy'], 'tab-a') + }, + desktop: { + panes: ['tab-a pty-a Terminal 1', 'tab-b pty-b Terminal 2'], + active: 'tab-a', + groups: oneGroup(['tab-a', 'tab-b'], 'tab-a') + } +} +const tabBarPair = (first: string, second: string) => { + const [firstTab, secondTab] = [first.split(' ')[0]!, second.split(' ')[0]!] + return { + panes: [`${first} Terminal 1`, `${second} Terminal 2`], + active: firstTab, + groups: oneGroup([firstTab, secondTab], firstTab) + } +} +const single = (pane: string) => { + const tab = pane.split(' ')[0]! + return { panes: [`${pane} Terminal 1`], active: tab, groups: oneGroup([tab], tab) } +} +const vimTab = single('tab-e pty-e') +const layoutless = { + main: single('tab-old pty-old'), + // The window binds a layoutless row's terminal when its pane mounts, not at startup. + desktop: { + panes: ['tab-old (no layout yet) Terminal 1'], + active: null, + groups: oneGroup(['tab-old'], 'tab-old') + } +} +const splitRowGroups = oneGroup(['tab-a', 'tab-b', 'tab-legacy'], 'tab-a') + +export const HEADLESS_PHONE_LAYOUT_CASES: readonly HeadlessPhoneLayoutCase[] = [ + { + name: 'legacy row repeating a tab-bar tab’s terminal', + build: () => legacyFixture('legacy row repeating a tab-bar tab’s terminal'), + main: { [GIT_KEY]: duplicate.main }, + desktop: { [GIT_KEY]: duplicate.desktop }, + phoneFollowsDesktop: [GIT_KEY] + }, + { + name: 'legacy row repeating a tab-bar tab’s terminal, selected', + build: () => legacyFixture('legacy row repeating a tab-bar tab’s terminal, selected'), + main: { + [GIT_KEY]: { + ...duplicate.main, + active: 'tab-legacy', + groups: oneGroup(['tab-a', 'tab-b', 'tab-legacy'], 'tab-legacy') + } + }, + desktop: { [GIT_KEY]: duplicate.desktop }, + phoneFollowsDesktop: [GIT_KEY] + }, + { + name: 'legacy row repeating an SSH relay terminal with no incarnation recorded', + build: sshRelayDuplicateSession, + main: { + [GIT_KEY]: { + ...duplicate.main, + panes: duplicate.main.panes.map((pane) => pane.replace('pty-a', 'ssh:target-1@@pty-1')) + } + }, + desktop: { + [GIT_KEY]: { + ...duplicate.desktop, + panes: duplicate.desktop.panes.map((pane) => pane.replace('pty-a', 'ssh:target-1@@pty-1')) + } + }, + phoneFollowsDesktop: [GIT_KEY] + }, + { + // The window unbinds the repeated pane but keeps the row; the phone keeps main's view. + name: 'legacy split row sharing one pane’s terminal with a tab-bar tab', + build: () => legacyFixture('legacy split row sharing one pane’s terminal with a tab-bar tab'), + main: { + [GIT_KEY]: { + panes: [ + 'tab-a pty-a Terminal 1', + 'tab-b pty-b Terminal 2', + 'tab-legacy pty-a Terminal 3', + 'tab-legacy pty-c Terminal 3' + ], + active: 'tab-a', + groups: splitRowGroups + } + }, + desktop: { + [GIT_KEY]: { + panes: [ + 'tab-a pty-a Terminal 1', + 'tab-b pty-b Terminal 2', + 'tab-legacy - Terminal 3', + 'tab-legacy pty-c Terminal 3' + ], + active: 'tab-a', + groups: splitRowGroups + } + }, + phoneFollowsDesktop: [] + }, + { + name: 'duplicate in one worktree, with a tombstone, a label and a layoutless row in others', + build: contaminationSession, + main: { + [A]: duplicate.main, + [B]: tabBarPair('tab-c pty-c', 'tab-d pty-d'), + [C]: vimTab, + [D]: layoutless.main + }, + desktop: { + [A]: duplicate.desktop, + [B]: tabBarPair('tab-c pty-c', 'tab-d pty-d'), + [C]: vimTab, + [D]: layoutless.desktop + }, + phoneFollowsDesktop: [A] + }, + { + name: 'tab-bar row whose stale row terminal is another tab’s pane', + build: staleRowTerminalSession, + main: { + [A]: tabBarPair('tab-a pty-a', 'tab-b pty-b'), + [C]: vimTab, + [D]: layoutless.main + }, + desktop: { + [A]: tabBarPair('tab-a pty-a', 'tab-b pty-b'), + [C]: vimTab, + [D]: layoutless.desktop + }, + phoneFollowsDesktop: [] + }, + { + name: 'one terminal in tab-bar tabs of two worktrees', + build: terminalInTwoWorktreesSession, + main: { [A]: single('tab-a pty-shared'), [B]: single('tab-b pty-shared') }, + desktop: { [A]: single('tab-a pty-shared'), [B]: single('tab-b pty-shared') }, + phoneFollowsDesktop: [] + } +] + +export const HEADLESS_PHONE_WORKTREES = { A, B, C, D }