mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* fix(mobile): keep phone tab selection across host snapshots Preserve device-owned tab focus across ordinary host republications while explicit follow navigation remains authoritative. Retire closed selections across clients so stale snapshots cannot resurrect tabs. * fix(mobile): acknowledge session tab closes * fix(mobile): avoid tombstones for uncommitted closes * fix(web): implement session close IPC stubs * refactor: simplify mobile tab close flow * fix: bound session tab close confirmation
343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
|
|
import type { PersistedMobileClientTabSelections } from '../../shared/persisted-state-types'
|
|
import {
|
|
activateClientSessionTabSelection,
|
|
ClientSessionTabSelectionStore,
|
|
deriveClientSessionTabSelection,
|
|
projectClientSessionTabSelection
|
|
} from './client-session-tab-selection'
|
|
import { normalizePersistedMobileClientTabSelections } from './client-session-tab-selection-persistence'
|
|
|
|
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('moves persisted selections when a worktree identity changes', () => {
|
|
const persisted: PersistedMobileClientTabSelections[] = []
|
|
const store = new ClientSessionTabSelectionStore()
|
|
store.activate(snapshot(), 'device-a', 'browser-unified')
|
|
store.setPersistListener((state) => persisted.push(state))
|
|
|
|
store.migrateWorktree('wt-1', 'wt-renamed')
|
|
|
|
expect(persisted).toEqual([
|
|
{
|
|
'device-a': {
|
|
'wt-renamed': {
|
|
activeTabId: 'browser-unified',
|
|
activeGroupId: 'group-right',
|
|
activeTabIdByGroupId: {
|
|
'group-left': 'terminal-a',
|
|
'group-right': 'browser-unified'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
])
|
|
expect(store.project({ ...snapshot(), worktree: 'wt-renamed' }, 'device-a').activeTabId).toBe(
|
|
'browser-unified'
|
|
)
|
|
})
|
|
|
|
it('does not persist topology-only projections from unrelated worktrees', () => {
|
|
const persisted: PersistedMobileClientTabSelections[] = []
|
|
const store = new ClientSessionTabSelectionStore()
|
|
store.setPersistListener((state) => persisted.push(state))
|
|
|
|
store.project({ ...snapshot(), worktree: 'listed-only' }, 'device-a')
|
|
store.activate(snapshot(), 'device-a', 'browser-unified')
|
|
|
|
expect(persisted).toEqual([
|
|
{
|
|
'device-a': {
|
|
'wt-1': {
|
|
activeTabId: 'browser-unified',
|
|
activeGroupId: 'group-right',
|
|
activeTabIdByGroupId: { 'group-right': 'browser-unified' }
|
|
}
|
|
}
|
|
}
|
|
])
|
|
|
|
store.forgetWorktree('listed-only')
|
|
expect(persisted).toHaveLength(1)
|
|
})
|
|
|
|
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(),
|
|
activeGroupId: null,
|
|
activeTabId: null,
|
|
activeTabType: null,
|
|
tabGroups: [],
|
|
tabs: []
|
|
}
|
|
expect(store.project(empty, 'device-a').activeTabId).toBeNull()
|
|
|
|
expect(store.project(snapshot(), 'device-a').activeTabId).toBe('browser-unified')
|
|
})
|
|
|
|
it('restores the selection after a snapshot transiently drops the selected tab', () => {
|
|
const store = new ClientSessionTabSelectionStore()
|
|
const full = snapshot()
|
|
store.activate(full, 'device-a', 'browser-unified')
|
|
|
|
// A browser guest process swap omits the tab from one snapshot.
|
|
const withoutBrowser = {
|
|
...full,
|
|
activeTabId: 'terminal-a::leaf-a',
|
|
activeTabType: 'terminal' as const,
|
|
tabs: full.tabs.filter((tab) => tab.id !== 'browser-unified')
|
|
}
|
|
expect(store.project(withoutBrowser, 'device-a').activeTabId).toBe('terminal-a::leaf-a')
|
|
|
|
expect(store.project(full, 'device-a').activeTabId).toBe('browser-unified')
|
|
})
|
|
|
|
it('does not restore a closed tab from a stale pre-close snapshot', () => {
|
|
const store = new ClientSessionTabSelectionStore()
|
|
const full = snapshot()
|
|
store.activate(full, 'device-a', 'browser-unified')
|
|
store.activate(full, 'device-b', 'browser-unified')
|
|
|
|
store.forgetTabs('wt-1', ['browser-unified'])
|
|
|
|
for (const clientId of ['device-a', 'device-b']) {
|
|
const stale = store.project(full, clientId)
|
|
expect(stale.activeTabId).toBe('terminal-a::leaf-a')
|
|
expect(stale.tabs.some((tab) => tab.id === 'browser-unified')).toBe(false)
|
|
}
|
|
|
|
const withoutBrowser = {
|
|
...full,
|
|
activeTabId: 'terminal-a::leaf-a',
|
|
activeTabType: 'terminal' as const,
|
|
tabGroups: full.tabGroups?.slice(0, 1),
|
|
tabs: full.tabs.filter((tab) => tab.id !== 'browser-unified')
|
|
}
|
|
store.project(withoutBrowser, 'device-a')
|
|
const reused = store.project(full, 'device-a')
|
|
expect(reused.activeTabId).toBe('terminal-a::leaf-a')
|
|
expect(reused.tabs.some((tab) => tab.id === 'browser-unified')).toBe(true)
|
|
})
|
|
|
|
it('lets an explicit activation reuse a recently closed tab identity', () => {
|
|
const store = new ClientSessionTabSelectionStore()
|
|
const full = snapshot()
|
|
store.activate(full, 'device-a', 'browser-unified')
|
|
store.forgetTabs('wt-1', ['browser-unified'])
|
|
|
|
const reopened = store.activate(full, 'device-a', 'browser-unified')
|
|
|
|
expect(reopened.activeTabId).toBe('browser-unified')
|
|
expect(reopened.tabs.some((tab) => tab.id === 'browser-unified')).toBe(true)
|
|
})
|
|
|
|
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({})
|
|
expect(normalizePersistedMobileClientTabSelections([{ 'wt-1': {} }])).toEqual({})
|
|
expect(
|
|
normalizePersistedMobileClientTabSelections({
|
|
'device-array': [{ activeTabId: 'tab-1' }],
|
|
'device-selection-array': { 'wt-1': ['tab-1'] },
|
|
'device-group-array': {
|
|
'wt-1': { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: ['tab-1'] }
|
|
}
|
|
})
|
|
).toEqual({
|
|
'device-group-array': {
|
|
'wt-1': { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: {} }
|
|
}
|
|
})
|
|
})
|
|
})
|