fix(runtime): keep the restored tab selected after a serve restart; update stale paired e2e specs (#39980)

* fix(runtime): keep a restored tab selected after orca serve restarts

Since #38844 a cold rebuild restores the saved tab groups, and a saved
group's null active tab was kept as an explicit "nothing selected". A
headless server never saves its own selection, so after a restart its
workspaces listed every terminal with none selected. Saved groups now
only declare a null selection when the session also saved an active
group; otherwise the restored tab stays selected, as before.

* test(e2e): bring layout-compat and paired-client specs up to current behavior

- old-phone baseline: phone snapshots carry tabGroupLayout since #38844.
- tab composition: drop onto where the active tab sits once a drag
  undocks it; create the server-placed browser page on the server, since
  a phone's create through the desktop is desktop-hosted (#37705); the
  desktop serves its phone the server page it mirrors (#36447).
- project removal: retry the host's "can't answer yet" terminal list and
  wait for a terminal whose shell has spawned.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-11-08 14:29:52 -08:00
committed by GitHub
co-authored by OrcaWin
parent 176e6a4e98
commit a49fca7a98
4 changed files with 56 additions and 27 deletions
@@ -259,11 +259,20 @@ export abstract class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspa
? mergedActiveTab.parentTabId
: mergedActiveTab.id
: null
const rebuiltTabGroups: RuntimeMobileSessionTabGroup[] = navigationGroups
// Saved groups without a saved active group record no selection; keep the restored tab selected.
const restoredGroups =
hasCurrentNavigation || navigationActiveGroupId || !activeTopLevelId
? navigationGroups
: navigationGroups?.map((group) =>
group.activeTabId === null && group.tabOrder.includes(activeTopLevelId)
? { ...group, activeTabId: activeTopLevelId }
: group
)
const rebuiltTabGroups: RuntimeMobileSessionTabGroup[] = restoredGroups
? appendBrowserTabOrder(
mergeMobileSessionTabGroups(
entryWorktreeId,
navigationGroups,
restoredGroups,
mergedTabs,
mergedActiveTab?.type === 'terminal' ? mergedActiveTab : null,
session.tabGroups?.[entryWorktreeId]
+1
View File
@@ -1332,6 +1332,7 @@
"activeTabType",
"publicationEpoch",
"snapshotVersion",
"tabGroupLayout",
"tabGroups",
"tabs",
"type",
@@ -11,6 +11,8 @@ import {
type PairedElectronClient
} from './helpers/paired-electron-client'
import { createRestartSession } from './helpers/orca-restart'
import { readFreshTerminalInventory } from './helpers/terminal-inventory-observation'
import type { RuntimeTerminalListResult } from '../../src/shared/runtime-types'
function seededRepoPathOrSkip(): string {
const repoPath = existsSync(TEST_REPO_PATH_FILE)
@@ -105,20 +107,27 @@ test('removing a project from a paired client stops its shells on the host', asy
state?.setActiveView('terminal')
state?.setActiveWorktree(id)
}, worktreeId)
const listHostTerminals = (): Promise<string[]> =>
callRuntime<{ terminals: { handle: string }[] }>(
paired.page,
paired.environmentId,
'terminal.list',
{ worktree: `id:${worktreeId}` }
).then((listed) => listed.terminals.map((terminal) => terminal.handle))
let handle: string | undefined
await expect
.poll(() => listHostTerminals().then((handles) => handles.length), {
timeout: 45_000,
message: 'Opening the worktree never created a host terminal'
})
.toBeGreaterThan(0)
const [handle] = await listHostTerminals()
.poll(
async () => {
const listed = await readFreshTerminalInventory(() =>
callRuntime<RuntimeTerminalListResult>(
paired.page,
paired.environmentId,
'terminal.list',
{
worktree: `id:${worktreeId}`
}
)
)
// A row listed before its shell spawns has no PTY yet, and its handle is replaced once one does.
handle = listed?.terminals.find((terminal) => terminal.ptyId)?.handle
return handle
},
{ timeout: 45_000, message: 'Opening the worktree never created a host terminal' }
)
.toBeDefined()
// Retried because the first send can land before the shell is writable or has its prompt.
await expect
@@ -75,11 +75,20 @@ function desktopTab(page: Page, hostTabId: string) {
/** Drops one desktop tab onto the leading edge of another, as a user drags in the tab strip. */
async function dragTab(page: Page, hostTabId: string, ontoHostTabId: string): Promise<void> {
// The active tab docks at a scrolled strip's edge until a drag starts, so drop where it really sits.
const onto = desktopTab(page, ontoHostTabId)
await onto.hover()
await page.mouse.wheel(0, -10_000)
await expect
.poll(() =>
onto.evaluate((tab) => tab.closest('[data-tab-strip-slot]')?.parentElement?.scrollLeft)
)
.toBe(0)
const from = (await desktopTab(page, hostTabId).boundingBox())!
const onto = (await desktopTab(page, ontoHostTabId).boundingBox())!
const target = (await onto.boundingBox())!
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
await page.mouse.down()
await page.mouse.move(onto.x + onto.width * 0.25, onto.y + onto.height / 2, { steps: 8 })
await page.mouse.move(target.x + target.width * 0.25, target.y + target.height / 2, { steps: 8 })
await page.mouse.up()
}
@@ -149,14 +158,15 @@ test('phone shows the desktop tab strip of a server workspace, also with the win
)
await expect.poll(() => desktop.page.locator(SORTABLE_TAB).count(), { timeout: 30_000 }).toBe(3)
// A server-placed browser page sits in the same strip.
await call(
socket,
'browser.tabCreate',
{ worktree, url: 'about:blank', activate: false },
z.looseObject({}),
serverHostId
)
// A server-placed browser page sits in the same strip. Created by the server itself: one the phone
// creates through the desktop is hosted by the desktop (relayed-browser-tab-create.ts).
const { browserPageId } = (
await host.client.call<{ browserPageId: string }>('browser.tabCreate', {
worktree,
url: 'about:blank',
activate: false
})
).result
const phoneStrip = async (): Promise<Strip> =>
((await phoneTabs()).tabGroups ?? []).map((group) => group.tabOrder)
await expect
@@ -206,9 +216,9 @@ test('phone shows the desktop tab strip of a server workspace, also with the win
(await phoneTabs()).tabs.filter((tab) => tab.type === 'browser').map((tab) => tab.id)
).toHaveLength(1)
expect(await publishedStrip()).toEqual(arranged)
// Only the server holds this workspace's tabs; the desktop's own answer has none of them.
// Only the server holds this workspace's tabs; the desktop serves its phone just the server page it mirrors (#36447).
const untargeted = await call(socket, 'session.tabs.list', { worktree }, SessionTabsSchema)
expect(untargeted.tabs).toEqual([])
expect(untargeted.tabs.map((tab) => tab.id)).toEqual([browserPageId])
// Selection is each device's own, as for a local workspace.
await call(