mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
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.
This commit is contained in:
@@ -9377,6 +9377,58 @@ describe('Store', () => {
|
||||
|
||||
// ── Live Claude PTY session ids (STA-1246) ─────────────────────────
|
||||
|
||||
describe('mobileClientTabSelectionsByDeviceId', () => {
|
||||
it('persists device tab selections across reloads and drops malformed payloads', async () => {
|
||||
const store = await createStore()
|
||||
store.setMobileClientTabSelections({
|
||||
'device-a': {
|
||||
'repo-1::/tmp/wt': { activeTabId: 'tab-1', activeGroupId: 'g1', activeTabIdByGroupId: {} }
|
||||
}
|
||||
})
|
||||
store.flush()
|
||||
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getMobileClientTabSelections()['device-a']?.['repo-1::/tmp/wt']).toEqual({
|
||||
activeTabId: 'tab-1',
|
||||
activeGroupId: 'g1',
|
||||
activeTabIdByGroupId: {}
|
||||
})
|
||||
|
||||
writeDataFile({ mobileClientTabSelectionsByDeviceId: { 'device-a': 'corrupt' } })
|
||||
const corrupted = await createStore()
|
||||
expect(corrupted.getMobileClientTabSelections()).toEqual({})
|
||||
})
|
||||
|
||||
it('prunes selections for a removed repo worktree', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
store.setMobileClientTabSelections({
|
||||
'device-a': {
|
||||
'r1::/tmp/wt': {
|
||||
activeTabId: 'tab-1',
|
||||
activeGroupId: null,
|
||||
activeTabIdByGroupId: {}
|
||||
},
|
||||
'other-repo::/tmp/wt': {
|
||||
activeTabId: 'tab-2',
|
||||
activeGroupId: null,
|
||||
activeTabIdByGroupId: {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
store.removeProject('r1')
|
||||
|
||||
expect(store.getMobileClientTabSelections()['device-a']).toEqual({
|
||||
'other-repo::/tmp/wt': {
|
||||
activeTabId: 'tab-2',
|
||||
activeGroupId: null,
|
||||
activeTabIdByGroupId: {}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('claudeLivePtySessionIds', () => {
|
||||
it('persists added ids across reloads and removes them durably', async () => {
|
||||
const store = await createStore()
|
||||
|
||||
@@ -48,6 +48,7 @@ import type {
|
||||
ProjectGroup,
|
||||
FolderWorkspace,
|
||||
SparsePreset,
|
||||
PersistedMobileClientTabSelections,
|
||||
WorktreeMeta,
|
||||
WorktreeLineage,
|
||||
WorkspaceLineage,
|
||||
@@ -77,6 +78,7 @@ import {
|
||||
} from '../shared/task-source-context'
|
||||
import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types'
|
||||
import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files'
|
||||
import { normalizePersistedMobileClientTabSelections } from './runtime/client-session-tab-selection'
|
||||
import { hardenExistingSecureFile } from '../shared/secure-file'
|
||||
import {
|
||||
LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS,
|
||||
@@ -3004,6 +3006,9 @@ export class Store {
|
||||
normalizedProjectGroups
|
||||
),
|
||||
worktreeLineageById: parsed.worktreeLineageById ?? {},
|
||||
mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections(
|
||||
parsed.mobileClientTabSelectionsByDeviceId
|
||||
),
|
||||
workspaceLineageByChildKey: normalizeWorkspaceLineageByChildKey(
|
||||
parsed.workspaceLineageByChildKey
|
||||
),
|
||||
@@ -4199,6 +4204,15 @@ export class Store {
|
||||
delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey]
|
||||
}
|
||||
}
|
||||
for (const selectionsByWorktree of Object.values(
|
||||
this.state.mobileClientTabSelectionsByDeviceId ?? {}
|
||||
)) {
|
||||
for (const key of Object.keys(selectionsByWorktree)) {
|
||||
if (belongsToHost(key)) {
|
||||
delete selectionsByWorktree[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateRepo(
|
||||
@@ -4438,6 +4452,17 @@ export class Store {
|
||||
|
||||
// ── Sparse Presets ─────────────────────────────────────────────────
|
||||
|
||||
// ── Mobile client tab selections ──────────────────────────────────
|
||||
|
||||
getMobileClientTabSelections(): PersistedMobileClientTabSelections {
|
||||
return this.state.mobileClientTabSelectionsByDeviceId ?? {}
|
||||
}
|
||||
|
||||
setMobileClientTabSelections(next: PersistedMobileClientTabSelections): void {
|
||||
this.state.mobileClientTabSelectionsByDeviceId = next
|
||||
this.scheduleSave()
|
||||
}
|
||||
|
||||
getSparsePresets(repoId: string): SparsePreset[] {
|
||||
return [...(this.state.sparsePresetsByRepo[repoId] ?? [])].sort((left, right) =>
|
||||
left.name.localeCompare(right.name)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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'
|
||||
|
||||
@@ -138,4 +140,75 @@ describe('client session-tab selection', () => {
|
||||
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({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
RuntimeMobileSessionClientTab,
|
||||
RuntimeMobileSessionTabsResult
|
||||
} from '../../shared/runtime-types'
|
||||
import type { PersistedMobileClientTabSelections } from '../../shared/types'
|
||||
|
||||
export type ClientSessionTabSelection = {
|
||||
activeTabId: string | null
|
||||
@@ -124,8 +125,90 @@ export function projectClientSessionTabSelection(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeClientSessionTabSelection(raw: unknown): ClientSessionTabSelection | null {
|
||||
if (typeof raw !== 'object' || raw === null) {
|
||||
return null
|
||||
}
|
||||
const candidate = raw as Partial<ClientSessionTabSelection>
|
||||
const activeTabId = typeof candidate.activeTabId === 'string' ? candidate.activeTabId : null
|
||||
const activeGroupId = typeof candidate.activeGroupId === 'string' ? candidate.activeGroupId : null
|
||||
const activeTabIdByGroupId: Record<string, string> = {}
|
||||
if (typeof candidate.activeTabIdByGroupId === 'object' && candidate.activeTabIdByGroupId) {
|
||||
for (const [groupId, tabId] of Object.entries(candidate.activeTabIdByGroupId)) {
|
||||
if (typeof tabId === 'string') {
|
||||
activeTabIdByGroupId[groupId] = tabId
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!activeTabId && !activeGroupId && Object.keys(activeTabIdByGroupId).length === 0) {
|
||||
return null
|
||||
}
|
||||
return { activeTabId, activeGroupId, activeTabIdByGroupId }
|
||||
}
|
||||
|
||||
// Why: this state comes off disk (and, for remote runtimes, another machine); a bad payload must degrade to "no selection", not throw.
|
||||
export function normalizePersistedMobileClientTabSelections(
|
||||
raw: unknown
|
||||
): PersistedMobileClientTabSelections {
|
||||
const normalized: PersistedMobileClientTabSelections = {}
|
||||
if (typeof raw !== 'object' || raw === null) {
|
||||
return normalized
|
||||
}
|
||||
for (const [clientNavigationId, selectionsByWorktree] of Object.entries(raw)) {
|
||||
if (typeof selectionsByWorktree !== 'object' || selectionsByWorktree === null) {
|
||||
continue
|
||||
}
|
||||
const entries: Record<string, ClientSessionTabSelection> = {}
|
||||
for (const [worktreeId, selection] of Object.entries(selectionsByWorktree)) {
|
||||
const normalizedSelection = normalizeClientSessionTabSelection(selection)
|
||||
if (normalizedSelection) {
|
||||
entries[worktreeId] = normalizedSelection
|
||||
}
|
||||
}
|
||||
if (Object.keys(entries).length > 0) {
|
||||
normalized[clientNavigationId] = entries
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export class ClientSessionTabSelectionStore {
|
||||
private statesByClient = new Map<string, Map<string, StoredClientSessionTabSelection>>()
|
||||
private persistListener: ((state: PersistedMobileClientTabSelections) => void) | null = null
|
||||
|
||||
// Why: selections previously died with the process, so a host restart snapped every phone back to the first tab (deterministic-topology fallback).
|
||||
hydrate(persisted: PersistedMobileClientTabSelections): void {
|
||||
for (const [clientNavigationId, selectionsByWorktree] of Object.entries(
|
||||
normalizePersistedMobileClientTabSelections(persisted)
|
||||
)) {
|
||||
const statesByWorktree = this.getStatesByWorktree(clientNavigationId)
|
||||
for (const [worktreeId, selection] of Object.entries(selectionsByWorktree)) {
|
||||
statesByWorktree.set(worktreeId, { selection, revision: 0 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setPersistListener(listener: (state: PersistedMobileClientTabSelections) => void): void {
|
||||
this.persistListener = listener
|
||||
}
|
||||
|
||||
serialize(): PersistedMobileClientTabSelections {
|
||||
const persisted: PersistedMobileClientTabSelections = {}
|
||||
for (const [clientNavigationId, statesByWorktree] of this.statesByClient) {
|
||||
const entries: Record<string, ClientSessionTabSelection> = {}
|
||||
for (const [worktreeId, state] of statesByWorktree) {
|
||||
entries[worktreeId] = state.selection
|
||||
}
|
||||
if (Object.keys(entries).length > 0) {
|
||||
persisted[clientNavigationId] = entries
|
||||
}
|
||||
}
|
||||
return persisted
|
||||
}
|
||||
|
||||
private persistNow(): void {
|
||||
this.persistListener?.(this.serialize())
|
||||
}
|
||||
|
||||
private getStatesByWorktree(
|
||||
clientNavigationId: string
|
||||
@@ -151,6 +234,14 @@ export class ClientSessionTabSelectionStore {
|
||||
selection: emptyClientSessionTabSelection(),
|
||||
revision: 0
|
||||
}
|
||||
if (snapshot.tabs.length === 0) {
|
||||
// Why: an empty snapshot has no topology to project; writing it back would wipe a restart-hydrated selection before tabs arrive.
|
||||
return {
|
||||
...snapshot,
|
||||
publicationEpoch: `${snapshot.publicationEpoch}:client-navigation`,
|
||||
snapshotVersion: snapshot.snapshotVersion + state.revision
|
||||
}
|
||||
}
|
||||
const projected = projectClientSessionTabSelection(snapshot, state.selection)
|
||||
statesByWorktree.set(snapshot.worktree, {
|
||||
selection: projected.selection,
|
||||
@@ -177,19 +268,26 @@ export class ClientSessionTabSelectionStore {
|
||||
selection: activateClientSessionTabSelection(snapshot, state.selection, activeTabId),
|
||||
revision: state.revision + 1
|
||||
})
|
||||
this.persistNow()
|
||||
return this.project(snapshot, clientNavigationId)
|
||||
}
|
||||
|
||||
forgetClient(clientNavigationId: string): void {
|
||||
this.statesByClient.delete(clientNavigationId)
|
||||
if (this.statesByClient.delete(clientNavigationId)) {
|
||||
this.persistNow()
|
||||
}
|
||||
}
|
||||
|
||||
forgetWorktree(worktreeId: string): void {
|
||||
let changed = false
|
||||
for (const [clientNavigationId, statesByWorktree] of this.statesByClient) {
|
||||
statesByWorktree.delete(worktreeId)
|
||||
changed = statesByWorktree.delete(worktreeId) || changed
|
||||
if (statesByWorktree.size === 0) {
|
||||
this.statesByClient.delete(clientNavigationId)
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
this.persistNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import WebSocket from 'ws'
|
||||
import { parsePairingCode } from '../../shared/pairing'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
|
||||
import type { PersistedMobileClientTabSelections } from '../../shared/types'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto'
|
||||
import { OrcaRuntimeRpcServer } from './runtime-rpc'
|
||||
@@ -578,4 +579,43 @@ describe('paired runtime navigation isolation', () => {
|
||||
expect(serverOne.hostSelections.tabId).toBe('host-tab')
|
||||
expect(serverTwo.hostSelections.tabId).toBe('host-tab')
|
||||
})
|
||||
|
||||
it('restores a device tab selection after a runtime restart', async () => {
|
||||
const persisted: { state: PersistedMobileClientTabSelections } = { state: {} }
|
||||
const makeStoreWithSelections = () => ({
|
||||
...makeStore(),
|
||||
getMobileClientTabSelections: () => persisted.state,
|
||||
setMobileClientTabSelections: (next: PersistedMobileClientTabSelections) => {
|
||||
persisted.state = next
|
||||
}
|
||||
})
|
||||
|
||||
const first = new OrcaRuntimeService(makeStoreWithSelections() as never)
|
||||
first.attachWindow(1)
|
||||
first.markGraphReady(1)
|
||||
seedSessionTabs(first)
|
||||
await first.activateMobileSessionTab(`id:${SESSION_WORKTREE_ID}`, 'client-a-tab', undefined, {
|
||||
notifyClients: false,
|
||||
clientNavigationId: 'device-a',
|
||||
navigation: 'caller'
|
||||
})
|
||||
expect(persisted.state['device-a']?.[SESSION_WORKTREE_ID]?.activeTabId).toBe('client-a-tab')
|
||||
|
||||
const restarted = new OrcaRuntimeService(makeStoreWithSelections() as never)
|
||||
restarted.attachWindow(1)
|
||||
restarted.markGraphReady(1)
|
||||
seedSessionTabs(restarted)
|
||||
const remembered = await restarted.listMobileSessionTabs(
|
||||
`id:${SESSION_WORKTREE_ID}`,
|
||||
'device-a'
|
||||
)
|
||||
expect(remembered.activeTabId).toBe('client-a-tab')
|
||||
expect(remembered.tabs.find((tab) => tab.isActive)?.id).toBe('client-a-tab')
|
||||
// Why: an unknown device must still start from deterministic topology, not inherit another device's restored state.
|
||||
const freshDevice = await restarted.listMobileSessionTabs(
|
||||
`id:${SESSION_WORKTREE_ID}`,
|
||||
'device-b'
|
||||
)
|
||||
expect(freshDevice.activeTabId).toBe('host-tab')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -913,6 +913,8 @@ type RuntimeStore = {
|
||||
deleteAutomation?: Store['deleteAutomation']
|
||||
getSparsePresets?: Store['getSparsePresets']
|
||||
saveSparsePreset?: Store['saveSparsePreset']
|
||||
getMobileClientTabSelections?: Store['getMobileClientTabSelections']
|
||||
setMobileClientTabSelections?: Store['setMobileClientTabSelections']
|
||||
getSettings(): {
|
||||
workspaceDir: string
|
||||
nestWorkspaces: boolean
|
||||
@@ -2766,6 +2768,14 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
) {
|
||||
this.store = store
|
||||
// Why: per-device tab selections must survive host restarts, or every phone snaps back to the first tab on return.
|
||||
const persistedClientTabSelections = store?.getMobileClientTabSelections?.()
|
||||
if (persistedClientTabSelections) {
|
||||
this.clientSessionTabSelections.hydrate(persistedClientTabSelections)
|
||||
}
|
||||
this.clientSessionTabSelections.setPersistListener((state) => {
|
||||
this.store?.setMobileClientTabSelections?.(state)
|
||||
})
|
||||
if (stats) {
|
||||
this.stats = stats
|
||||
this.agentDetector = new AgentDetector(stats)
|
||||
|
||||
@@ -3403,6 +3403,19 @@ export type LegacyPaneKeyAliasEntry = {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** Last tab selection a paired client made in a worktree; restores phone navigation across host restarts. */
|
||||
export type PersistedMobileClientTabSelection = {
|
||||
activeTabId: string | null
|
||||
activeGroupId: string | null
|
||||
activeTabIdByGroupId: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
/** deviceId → worktreeId → selection. */
|
||||
export type PersistedMobileClientTabSelections = Record<
|
||||
string,
|
||||
Record<string, PersistedMobileClientTabSelection>
|
||||
>
|
||||
|
||||
// ─── Persistence shape ──────────────────────────────────────────────
|
||||
export type PersistedState = {
|
||||
schemaVersion: number
|
||||
@@ -3413,6 +3426,8 @@ export type PersistedState = {
|
||||
folderWorkspaces: FolderWorkspace[]
|
||||
/** Sparse-checkout presets keyed by repoId. */
|
||||
sparsePresetsByRepo: Record<string, SparsePreset[]>
|
||||
/** Per paired device last tab selection by worktree; keeps mobile navigation across host restarts. */
|
||||
mobileClientTabSelectionsByDeviceId?: PersistedMobileClientTabSelections
|
||||
worktreeMeta: Record<string, WorktreeMeta>
|
||||
worktreeLineageById: Record<string, WorktreeLineage>
|
||||
workspaceLineageByChildKey: Record<WorkspaceKey, WorkspaceLineage>
|
||||
|
||||
Reference in New Issue
Block a user