diff --git a/src/main/runtime/mobile-session-chat-tab-order.test.ts b/src/main/runtime/mobile-session-chat-tab-order.test.ts new file mode 100644 index 00000000000..76b33b4134e --- /dev/null +++ b/src/main/runtime/mobile-session-chat-tab-order.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionSnapshotTab } from '../../shared/runtime-types' +import { appendChatTabOrder, keepSnapshotChatTabs } from './mobile-session-tab-merge' + +const chat: RuntimeMobileSessionSnapshotTab = { + type: 'agent-session', + id: 'agent-session:claude_a', + title: 'Claude', + sessionId: 'claude_a', + agent: 'claude', + isActive: true +} +const terminal: RuntimeMobileSessionSnapshotTab = { + type: 'terminal', + id: 'term::leaf', + parentTabId: 'term', + leafId: 'leaf', + title: 'Terminal', + isActive: true +} + +describe('chat tabs across a rebuild from persisted state', () => { + it('keeps the chat the only active tab when it was the one shown', () => { + const tabs = keepSnapshotChatTabs([terminal], { tabs: [chat], activeTabId: chat.id }) + + expect(tabs.map((tab) => [tab.id, tab.isActive])).toEqual([ + [terminal.id, false], + [chat.id, true] + ]) + }) + + it('returns a chat to the place it held, not the end', () => { + const groups = appendChatTabOrder( + [{ id: 'g', activeTabId: 'term', tabOrder: ['term', 'other'] }], + [{ id: 'g', activeTabId: chat.id, tabOrder: [chat.id, 'term', 'editor', 'other'] }], + [chat, terminal], + chat.id + ) + + expect(groups).toEqual([ + { id: 'g', activeTabId: chat.id, tabOrder: [chat.id, 'term', 'other'] } + ]) + }) + + it('follows the tab it sat after when that tab survives', () => { + const groups = appendChatTabOrder( + [{ id: 'g', activeTabId: 'term', tabOrder: ['term', 'other'] }], + [{ id: 'g', activeTabId: 'term', tabOrder: ['term', chat.id, 'other'] }], + [{ ...chat, isActive: false }, terminal] + ) + + expect(groups[0]?.tabOrder).toEqual(['term', chat.id, 'other']) + expect(groups[0]?.activeTabId).toBe('term') + }) +}) diff --git a/src/main/runtime/mobile-session-tab-merge.ts b/src/main/runtime/mobile-session-tab-merge.ts index b907fbb509d..ea0b34e907f 100644 --- a/src/main/runtime/mobile-session-tab-merge.ts +++ b/src/main/runtime/mobile-session-tab-merge.ts @@ -129,3 +129,73 @@ export function mergeMobileSessionTabGroups( }) .filter((group) => group.tabOrder.length > 0) } + +/** + * A tab list rebuilt from persisted state, plus the chat tabs only `prior` holds. Chat tabs exist + * only in the runtime's snapshot, so no persisted terminal or layout state can recreate them. + */ +export function keepSnapshotChatTabs( + rebuilt: readonly RuntimeMobileSessionSnapshotTab[], + prior: + | { tabs: readonly RuntimeMobileSessionSnapshotTab[]; activeTabId: string | null } + | undefined +): RuntimeMobileSessionSnapshotTab[] { + const chatTabs = prior?.tabs.filter((tab) => tab.type === 'agent-session') ?? [] + if (chatTabs.length === 0) { + return [...rebuilt] + } + // One active tab: a rebuilt terminal's own flag must not outrank the chat the snapshot showed. + const activeChatId = chatTabs.find((tab) => tab.id === prior?.activeTabId)?.id ?? null + return [ + ...rebuilt.map((tab) => (activeChatId && tab.isActive ? { ...tab, isActive: false } : tab)), + ...chatTabs.map((tab) => ({ ...tab, isActive: tab.id === activeChatId })) + ] +} + +/** + * Puts chat tabs back into the group order a terminal-only merge rebuilt without them: each in the + * group that listed it before (else the first), after the tab it followed there. + */ +export function appendChatTabOrder( + groups: readonly RuntimeMobileSessionTabGroup[], + priorGroups: readonly RuntimeMobileSessionTabGroup[] | undefined, + tabs: readonly RuntimeMobileSessionSnapshotTab[], + activeTabId: string | null = tabs.find((tab) => tab.isActive)?.id ?? null +): RuntimeMobileSessionTabGroup[] { + const chatTabIds = tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.id) + if (chatTabIds.length === 0) { + return [...groups] + } + const next = groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] })) + for (const tabId of chatTabIds) { + const priorGroup = priorGroups?.find((group) => group.tabOrder.includes(tabId)) + const target = + next.find((group) => group.tabOrder.includes(tabId)) ?? + next.find((group) => group.id === priorGroup?.id) ?? + next[0] + if (!target) { + continue + } + if (!target.tabOrder.includes(tabId)) { + const at = chatInsertIndex(target.tabOrder, priorGroup?.tabOrder ?? [], tabId) + target.tabOrder.splice(at, 0, tabId) + } + if (tabId === activeTabId) { + target.activeTabId = tabId + } + } + return next +} + +function chatInsertIndex( + order: readonly string[], + priorOrder: readonly string[], + tabId: string +): number { + const priorIndex = priorOrder.indexOf(tabId) + if (priorIndex === -1) { + return order.length + } + const previous = priorOrder.slice(0, priorIndex).findLast((id) => order.includes(id)) + return previous === undefined ? 0 : order.indexOf(previous) + 1 +} diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-headless-chat-rebuild.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-headless-chat-rebuild.spec.ts new file mode 100644 index 00000000000..c5ec65e53b7 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-headless-chat-rebuild.spec.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import { + HEADLESS_LEAF_ID, + TEST_WORKTREE_ID, + TEST_WORKTREE_PATH, + makeHeadlessTerminalLayout, + makeRuntimeStoreWithWorkspaceSession, + makeWorkspaceSessionWithHeadlessTerminal +} from '../orca-runtime-test-fixtures.spec' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' + +// #29968: on a host with no window a chat tab lives only in the runtime's snapshot; rebuilding that +// snapshot from the saved terminals and editors must not drop it. +const WORKTREE = `id:${TEST_WORKTREE_ID}` +const README = `${TEST_WORKTREE_PATH}/README.md` +const CHAT_TAB_ID = 'agent-session:claude_29968' +const SERVE_PTY_ID = 'serve-29968-pty' + +function sessionWithTerminalAndEditor(): WorkspaceSessionState { + return makeWorkspaceSessionWithHeadlessTerminal({ + activeTabId: 'serve-tab', + activeTabIdByWorktree: { [TEST_WORKTREE_ID]: 'serve-tab' }, + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'serve-tab', + ptyId: SERVE_PTY_ID, + worktreeId: TEST_WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'serve-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: SERVE_PTY_ID }) + }, + openFilesByWorktree: { + [TEST_WORKTREE_ID]: [ + { + filePath: README, + relativePath: 'README.md', + worktreeId: TEST_WORKTREE_ID, + language: 'markdown', + runtimeEnvironmentId: null + } + ] + }, + unifiedTabs: { + [TEST_WORKTREE_ID]: [ + { + id: 'tab-readme', + entityId: README, + groupId: 'group-1', + worktreeId: TEST_WORKTREE_ID, + contentType: 'editor', + label: 'README.md', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 2 + } + ] + } + }) +} + +function headlessRuntimeWithChat() { + const harness = makeRuntimeStoreWithWorkspaceSession(sessionWithTerminalAndEditor()) + const runtime = new OrcaRuntimeService(harness.runtimeStore) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: vi.fn(async () => [{ id: SERVE_PTY_ID, cwd: TEST_WORKTREE_PATH, title: 'sh' }]) + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + return runtime +} + +async function openChat(runtime: OrcaRuntimeService): Promise { + await runtime.listMobileSessionTabs(WORKTREE) + runtime.projectStructuredAgentSessionTab({ + workspaceId: TEST_WORKTREE_ID, + sessionId: 'claude_29968', + agent: 'claude', + activate: true + }) +} + +describe('a chat tab on a host with no window', () => { + it('survives the rebuild that closing a saved editor tab forces', async () => { + const runtime = headlessRuntimeWithChat() + await openChat(runtime) + expect((await runtime.listMobileSessionTabs(WORKTREE)).tabs.map((tab) => tab.id)).toContain( + CHAT_TAB_ID + ) + + await runtime.closeMobileSessionTab(WORKTREE, 'tab-readme', { reason: 'user' }) + + const listed = await runtime.listMobileSessionTabs(WORKTREE) + expect(listed.tabs.map((tab) => tab.id)).toContain(CHAT_TAB_ID) + expect(listed.tabs.map((tab) => tab.id)).not.toContain('tab-readme') + expect(listed.tabGroups?.flatMap((group) => group.tabOrder)).toContain(CHAT_TAB_ID) + // The chat stays the tab the host shows, not the terminal the rebuild re-marked active. + expect(listed.activeTabId).toBe(CHAT_TAB_ID) + expect(listed.tabs.filter((tab) => tab.isActive).map((tab) => tab.id)).toEqual([CHAT_TAB_ID]) + }) + + it('keeps its place in the tab order when the host lists the workspace again', async () => { + const runtime = headlessRuntimeWithChat() + await openChat(runtime) + + const listed = await runtime.listMobileSessionTabs(WORKTREE) + + expect(listed.tabGroups?.flatMap((group) => group.tabOrder)).toEqual([ + 'serve-tab', + 'tab-readme', + CHAT_TAB_ID + ]) + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 37fb2e153c8..2c067cd87bc 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -86,6 +86,7 @@ await import('./orca-runtime-tests/mobile-session-tabs-part-13.spec') await import('./orca-runtime-tests/mobile-session-tabs-part-14.spec') await import('./orca-runtime-tests/mobile-session-tabs-cold-serve-hydrate.spec') await import('./orca-runtime-tests/mobile-session-tabs-empty-headless-snapshot.spec') +await import('./orca-runtime-tests/mobile-session-tabs-headless-chat-rebuild.spec') await import('./orca-runtime-tests/mobile-session-tabs-shell-selection.spec') await import('./orca-runtime-tests/mobile-session-tabs-windowless-sleep-resume.spec') await import('./orca-runtime-tests/exit-retirement-activation.spec') diff --git a/src/main/runtime/session-tabs/mobile-session-hydration.ts b/src/main/runtime/session-tabs/mobile-session-hydration.ts index 0b9e21c9df3..0252907d6db 100644 --- a/src/main/runtime/session-tabs/mobile-session-hydration.ts +++ b/src/main/runtime/session-tabs/mobile-session-hydration.ts @@ -22,6 +22,8 @@ import { pickRestoredActiveGroupId } from '../mobile-session-layout-projection' import { + appendChatTabOrder, + keepSnapshotChatTabs, mergeMobileSessionSnapshotTabs, mergeMobileSessionTabGroups } from '../mobile-session-tab-merge' @@ -243,7 +245,7 @@ export abstract class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspa const mergedTabs = options.onlyRuntimeOwnedTerminals === true && existing ? mergeMobileSessionSnapshotTabs(existing.tabs, tabs) - : tabs + : keepSnapshotChatTabs(tabs, existing) const mergedActiveTab = existing?.tabs.find((tab) => tab.id === existing.activeTabId) ?? activeTab ?? @@ -269,7 +271,7 @@ export abstract class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspa ? mergedActiveTab.parentTabId : mergedActiveTab.id : null - const nextTabGroups: RuntimeMobileSessionTabGroup[] = hasPersistedSplit + const rebuiltTabGroups: RuntimeMobileSessionTabGroup[] = hasPersistedSplit ? appendBrowserTabOrder( distributeHeadlessTabsAcrossGroups( persistedGroups.map((group) => ({ @@ -306,6 +308,7 @@ export abstract class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspa tabOrder } ] + const nextTabGroups = appendChatTabOrder(rebuiltTabGroups, existing?.tabGroups, mergedTabs) // Why: merging runtime tabs INTO a renderer publication must not reclass // the snapshot as headless-built — the preservation predicate would then // treat the renderer's own tabs as runtime-owned and resurrect tabs the diff --git a/src/main/runtime/session-tabs/mobile-session-preservation.ts b/src/main/runtime/session-tabs/mobile-session-preservation.ts index e3a7e4ddd09..312975f7df6 100644 --- a/src/main/runtime/session-tabs/mobile-session-preservation.ts +++ b/src/main/runtime/session-tabs/mobile-session-preservation.ts @@ -1,12 +1,11 @@ import type { OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab as StoredMobileSnapshotHasStalePreservedTabMethods } from './mobile-session-publication-preservation' import { OrcaRuntimeWithSyncMobileSessionTabs } from './mobile-session-reconciliation' import type { - RuntimeMobileSessionSnapshotTab, - RuntimeMobileSessionTabGroup, RuntimeMobileSessionTabsSnapshot, RuntimeMobileSessionTerminalTab } from '../../../shared/runtime-types' import { + appendChatTabOrder, getMobileSessionSnapshotTabIdentityKeys, mergeMobileSessionSnapshotTabs, mergeMobileSessionTabGroups @@ -74,7 +73,7 @@ export abstract class OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs ext activeGroupId: snapshot.activeGroupId ?? existing.activeGroupId, activeTabId: activeTab?.id ?? null, activeTabType: activeTab?.type ?? null, - tabGroups: this.mergeStructuredAgentSessionTabGroups( + tabGroups: appendChatTabOrder( tabGroups, existing.tabGroups ?? [], normalizedPreservedTabs, @@ -84,30 +83,6 @@ export abstract class OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs ext } } - protected mergeStructuredAgentSessionTabGroups( - groups: readonly RuntimeMobileSessionTabGroup[], - existingGroups: readonly RuntimeMobileSessionTabGroup[], - preservedTabs: readonly RuntimeMobileSessionSnapshotTab[], - activeTabId: string | null - ): RuntimeMobileSessionTabGroup[] { - const structuredTabs = preservedTabs.filter((tab) => tab.type === 'agent-session') - if (structuredTabs.length === 0) { - return [...groups] - } - const next = groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] })) - for (const tab of structuredTabs) { - const priorGroupId = existingGroups.find((group) => group.tabOrder.includes(tab.id))?.id - const target = next.find((group) => group.id === priorGroupId) ?? next[0] - if (target && !target.tabOrder.includes(tab.id)) { - target.tabOrder.push(tab.id) - } - if (target && tab.id === activeTabId) { - target.activeTabId = tab.id - } - } - return next - } - // Why: omitted from the renderer's publication AND from persistence = durably closed; a create in flight is not. protected listRendererRetiredTerminalTabs( incoming: RuntimeMobileSessionTabsSnapshot, @@ -204,11 +179,16 @@ export abstract class OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs ext activeGroupId: existing.activeGroupId ?? getHeadlessMobileSessionGroupId(existing.worktree), activeTabId: activeTab?.id ?? null, activeTabType: activeTab?.type ?? null, - tabGroups: mergeMobileSessionTabGroups( - existing.worktree, + tabGroups: appendChatTabOrder( + mergeMobileSessionTabGroups( + existing.worktree, + existing.tabGroups ?? [], + terminalTabs, + activeTab?.type === 'terminal' ? activeTab : null + ), existing.tabGroups ?? [], - terminalTabs, - activeTab?.type === 'terminal' ? activeTab : null + tabs, + activeTab?.id ?? null ), tabs } diff --git a/src/main/runtime/session-tabs/pty-backed-session-publication.ts b/src/main/runtime/session-tabs/pty-backed-session-publication.ts index e974787a07f..8a9ae9094fb 100644 --- a/src/main/runtime/session-tabs/pty-backed-session-publication.ts +++ b/src/main/runtime/session-tabs/pty-backed-session-publication.ts @@ -12,6 +12,7 @@ import { getHeadlessMobileSessionGroupId } from '../mobile-session-layout-projection' import { + appendChatTabOrder, mergeMobileSessionSnapshotTabs, mergeMobileSessionTabGroups } from '../mobile-session-tab-merge' @@ -147,11 +148,16 @@ export abstract class OrcaRuntimeWithPublishPtyBackedMobileSessionTerminal exten activeGroupId: existing?.activeGroupId ?? getHeadlessMobileSessionGroupId(worktreeId), activeTabId: activeTab?.id ?? null, activeTabType: activeTab?.type ?? null, - tabGroups: mergeMobileSessionTabGroups( - worktreeId, + tabGroups: appendChatTabOrder( + mergeMobileSessionTabGroups( + worktreeId, + existing?.tabGroups ?? [], + terminalTabs, + activeTab?.type === 'terminal' ? activeTab : null + ), existing?.tabGroups ?? [], - terminalTabs, - activeTab?.type === 'terminal' ? activeTab : null + tabs, + activeTab?.id ?? null ), ...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), tabs diff --git a/tests/e2e/headless-serve-mobile-chat-tab-order.spec.ts b/tests/e2e/headless-serve-mobile-chat-tab-order.spec.ts new file mode 100644 index 00000000000..8ef29e2543d --- /dev/null +++ b/tests/e2e/headless-serve-mobile-chat-tab-order.spec.ts @@ -0,0 +1,78 @@ +/** + * #29968: a phone paired straight to a headless `orca serve` keeps a Claude chat tab, in its place + * in the tab order, while the phone closes and opens terminals around it. + */ +import { randomUUID } from 'node:crypto' +import { expect, test } from './helpers/orca-app' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' +import { pairMobileClient } from './helpers/paired-mobile-client' +import { + createStructuredAgentSessionId, + structuredAgentSessionCreateParams +} from '../../src/shared/structured-agent-session-create' +import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-session-contracts' +import type { RuntimeWorktreePsSummary } from '../../src/shared/runtime-worktree-contracts' + +test('a headless host keeps the phone its chat tab as terminals come and go', async ({ + testRepoPath +}) => { + test.setTimeout(240_000) + const host = await launchHeadlessPairedRuntimeHost({ pairingScope: 'mobile' }) + const phone = pairMobileClient(host.offer) + try { + await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }) + let worktreeId = '' + await expect + .poll( + async () => { + const { worktrees } = await phone.call<{ worktrees: RuntimeWorktreePsSummary[] }>( + 'worktree.ps', + { limit: 1_000 } + ) + worktreeId = worktrees.find((row) => row.path === testRepoPath)?.worktreeId ?? '' + return worktreeId + }, + { timeout: 30_000 } + ) + .not.toBe('') + const worktree = `id:${worktreeId}` + const { tab: oldTerminal } = await phone.call<{ tab: { parentTabId: string } }>( + 'session.tabs.createTerminal', + { worktree, clientMutationId: 'old', activate: true, select: true, navigation: 'caller' } + ) + const sessionId = createStructuredAgentSessionId('claude', randomUUID) + await phone.call( + 'agentSession.create', + structuredAgentSessionCreateParams({ + sessionId, + worktree, + agent: 'claude', + randomUuid: randomUUID + }) + ) + const chatTabId = `agent-session:${sessionId}` + + // The reported sequence: close the old terminal, then a setup terminal opens beside the chat. + await phone.call('session.tabs.close', { + worktree, + tabId: oldTerminal.parentTabId, + reason: 'user' + }) + await host.client.call('terminal.create', { worktree, command: 'echo setup' }) + await phone.call('session.tabs.createTerminal', { + worktree, + clientMutationId: 'new', + activate: true, + select: true, + navigation: 'caller' + }) + + const listed = await phone.call('session.tabs.list', { + worktree + }) + expect(listed.tabs.map((tab) => tab.id)).toContain(chatTabId) + expect(listed.tabGroups?.flatMap((group) => group.tabOrder)).toContain(chatTabId) + } finally { + await host.dispose() + } +})