fix(mobile): keep a headless host's chat tab through snapshot rebuilds (#30028)

On a host with no window, chat tabs live only in the runtime's session snapshot. A forced rebuild from the saved session (closing a saved editor tab, an expired SSH reattach) dropped them, and every terminal-only group merge removed them from the tab order.

Refs #29968

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-17 01:23:10 -07:00
committed by GitHub
co-authored by Claude
parent 7adcefb078
commit 0ba5cccf9d
8 changed files with 355 additions and 37 deletions
@@ -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')
})
})
@@ -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
}
@@ -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<void> {
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
])
})
})
+1
View File
@@ -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')
@@ -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
@@ -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
}
@@ -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
@@ -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<RuntimeMobileSessionTabsResult>('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()
}
})