Files
orca/src/main/runtime/client-session-tab-selection.test.ts
T
Brennan Benson a26fd44d7e fix(mobile): persist per-device tab selection so worktree return restores the last open tab
A phone's tab selection lived only in the host's in-memory
ClientSessionTabSelectionStore. Any host restart wiped it, and the
per-device projection then fell back to deterministic topology, so
returning to a worktree on mobile always landed on the first tab
instead of the tab last opened on the phone.

Persist the per-device selections in the Store (keyed deviceId ->
worktreeId), hydrate them when the runtime constructs, and guard
projection so an early empty snapshot after restart cannot wipe a
hydrated selection before tabs arrive. Selections are pruned with the
worktree/repo and on device revoke, and malformed persisted payloads
degrade to empty instead of throwing.
2026-07-21 17:17:53 -07:00

215 lines
7.8 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
import type { PersistedMobileClientTabSelections } from '../../shared/types'
import {
activateClientSessionTabSelection,
ClientSessionTabSelectionStore,
deriveClientSessionTabSelection,
normalizePersistedMobileClientTabSelections,
projectClientSessionTabSelection
} from './client-session-tab-selection'
function snapshot(activeTabId = 'terminal-a::leaf-a'): RuntimeMobileSessionTabsResult {
const tabs = [
{
type: 'terminal' as const,
id: 'terminal-a::leaf-a',
parentTabId: 'terminal-a',
leafId: 'leaf-a',
title: 'A',
isActive: activeTabId === 'terminal-a::leaf-a',
status: 'ready' as const,
terminal: 'term-a'
},
{
type: 'terminal' as const,
id: 'terminal-a::leaf-b',
parentTabId: 'terminal-a',
leafId: 'leaf-b',
title: 'A split',
isActive: activeTabId === 'terminal-a::leaf-b',
status: 'ready' as const,
terminal: 'term-b'
},
{
type: 'browser' as const,
id: 'browser-unified',
browserWorkspaceId: 'browser-workspace',
browserPageId: 'page-1',
title: 'Browser',
url: 'about:blank',
loading: false,
canGoBack: false,
canGoForward: false,
isActive: activeTabId === 'browser-unified'
}
]
return {
worktree: 'wt-1',
publicationEpoch: 'renderer:1',
snapshotVersion: 1,
activeGroupId: 'group-left',
activeTabId,
activeTabType: activeTabId === 'browser-unified' ? 'browser' : 'terminal',
tabGroups: [
{ id: 'group-left', activeTabId: 'terminal-a', tabOrder: ['terminal-a'] },
{ id: 'group-right', activeTabId: 'browser-unified', tabOrder: ['browser-unified'] }
],
tabs
}
}
describe('client session-tab selection', () => {
it('keeps a client selection when a later host snapshot activates another tab', () => {
const initial = snapshot()
const selected = activateClientSessionTabSelection(
initial,
deriveClientSessionTabSelection(initial),
'browser-unified'
)
const hostChanged = snapshot('terminal-a::leaf-b')
const projected = projectClientSessionTabSelection(hostChanged, selected)
expect(projected.snapshot.activeTabId).toBe('browser-unified')
expect(projected.snapshot.activeGroupId).toBe('group-right')
expect(projected.snapshot.tabGroups?.[1]?.activeTabId).toBe('browser-unified')
})
it('tracks split-leaf focus while group selection uses the parent tab id', () => {
const initial = snapshot()
const selected = activateClientSessionTabSelection(
initial,
deriveClientSessionTabSelection(initial),
'terminal-a::leaf-b'
)
const projected = projectClientSessionTabSelection(initial, selected)
expect(projected.snapshot.activeTabId).toBe('terminal-a::leaf-b')
expect(projected.snapshot.tabGroups?.[0]?.activeTabId).toBe('terminal-a')
})
it('falls back within the selected group when the selected tab disappears', () => {
const initial = snapshot()
const selected = activateClientSessionTabSelection(
initial,
deriveClientSessionTabSelection(initial),
'terminal-a::leaf-b'
)
const removed = {
...initial,
activeGroupId: 'group-left',
activeTabId: 'terminal-a::leaf-a',
activeTabType: 'terminal' as const,
tabs: initial.tabs.filter((tab) => tab.id !== 'terminal-a::leaf-b')
}
const projected = projectClientSessionTabSelection(removed, selected)
expect(projected.snapshot.activeGroupId).toBe('group-left')
expect(projected.snapshot.activeTabId).toBe('terminal-a::leaf-a')
})
it('namespaces projections by device and discards revoked state', () => {
const store = new ClientSessionTabSelectionStore()
const initial = snapshot()
store.project(initial, 'device-a')
store.project(initial, 'device-b')
const selectedA = store.activate(initial, 'device-a', 'browser-unified')
expect(selectedA.activeTabId).toBe('browser-unified')
expect(selectedA.publicationEpoch).toBe('renderer:1:client-navigation')
expect(selectedA.snapshotVersion).toBe(2)
expect(store.project(initial, 'device-b').activeTabId).toBe('terminal-a::leaf-a')
const selectedAgain = store.activate(initial, 'device-a', 'terminal-a::leaf-b')
expect(selectedAgain.snapshotVersion).toBe(3)
expect(selectedAgain.activeTabId).toBe('terminal-a::leaf-b')
store.forgetClient('device-a')
expect(store.project(initial, 'device-a').activeTabId).toBe('terminal-a::leaf-a')
})
it('does not expose host focus when initializing a new paired device', () => {
const store = new ClientSessionTabSelectionStore()
const hostFocusedBrowser = snapshot('browser-unified')
const projected = store.project(hostFocusedBrowser, 'new-device')
expect(projected.activeTabId).toBe('terminal-a::leaf-a')
expect(projected.activeGroupId).toBe('group-left')
expect(projected.tabs.find((tab) => tab.isActive)?.id).toBe('terminal-a::leaf-a')
})
it('persists activations and restores them across a store rebuild (host restart)', () => {
const persisted: PersistedMobileClientTabSelections[] = []
const store = new ClientSessionTabSelectionStore()
store.setPersistListener((state) => persisted.push(state))
store.activate(snapshot(), 'device-a', 'browser-unified')
expect(persisted).toHaveLength(1)
expect(persisted[0]?.['device-a']?.['wt-1']?.activeTabId).toBe('browser-unified')
const restarted = new ClientSessionTabSelectionStore()
restarted.hydrate(persisted[0]!)
const projected = restarted.project(snapshot(), 'device-a')
expect(projected.activeTabId).toBe('browser-unified')
expect(projected.tabs.find((tab) => tab.isActive)?.id).toBe('browser-unified')
expect(restarted.project(snapshot(), 'device-b').activeTabId).toBe('terminal-a::leaf-a')
})
it('persists forgetClient and forgetWorktree removals', () => {
const persisted: PersistedMobileClientTabSelections[] = []
const store = new ClientSessionTabSelectionStore()
store.activate(snapshot(), 'device-a', 'browser-unified')
store.setPersistListener((state) => persisted.push(state))
store.forgetWorktree('wt-1')
expect(persisted.at(-1)).toEqual({})
store.activate(snapshot(), 'device-a', 'browser-unified')
store.forgetClient('device-a')
expect(persisted.at(-1)).toEqual({})
// Why: forgetting state that is already gone must not rewrite the persisted file.
const writes = persisted.length
store.forgetClient('device-a')
store.forgetWorktree('wt-1')
expect(persisted.length).toBe(writes)
})
it('does not let an empty snapshot wipe a hydrated selection before tabs arrive', () => {
const store = new ClientSessionTabSelectionStore()
store.hydrate({
'device-a': {
'wt-1': { activeTabId: 'browser-unified', activeGroupId: null, activeTabIdByGroupId: {} }
}
})
const empty = { ...snapshot(), tabGroups: [], tabs: [] }
expect(store.project(empty, 'device-a').activeTabId).toBe('terminal-a::leaf-a')
expect(store.project(snapshot(), 'device-a').activeTabId).toBe('browser-unified')
})
it('drops malformed persisted payloads instead of hydrating them', () => {
expect(
normalizePersistedMobileClientTabSelections({
'device-a': {
'wt-1': { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: { g: 'tab' } },
'wt-bad': { activeTabId: 42, activeGroupId: null, activeTabIdByGroupId: { g: 7 } }
},
'device-bad': 'nope',
'device-empty': {}
})
).toEqual({
'device-a': {
'wt-1': { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: { g: 'tab' } }
}
})
expect(normalizePersistedMobileClientTabSelections(null)).toEqual({})
expect(normalizePersistedMobileClientTabSelections('garbage')).toEqual({})
})
})