From 24b8fcc91834888c8a6efec403ee1a87ad7281da Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:12:38 -0700 Subject: [PATCH] fix(mobile): restore the last-open tab when returning to a worktree on mobile (#9801) * 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. * fix(mobile): harden persisted tab selection cleanup * fix(mobile): preserve tab selection across worktree rename --- src/main/persistence.test.ts | 120 +++++++++++++++ src/main/persistence.ts | 41 +++++ ...lient-session-tab-selection-persistence.ts | 61 ++++++++ .../client-session-tab-selection.test.ts | 145 ++++++++++++++++++ .../runtime/client-session-tab-selection.ts | 98 +++++++++++- ...t-navigation-isolation.integration.test.ts | 40 +++++ src/main/runtime/orca-runtime.ts | 11 ++ src/shared/types.ts | 15 ++ 8 files changed, 525 insertions(+), 6 deletions(-) create mode 100644 src/main/runtime/client-session-tab-selection-persistence.ts diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index fb65a4cf8..5124b34c2 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -9703,6 +9703,110 @@ 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') + store.flush() + + expect(store.getMobileClientTabSelections()['device-a']).toEqual({ + 'other-repo::/tmp/wt': { + activeTabId: 'tab-2', + activeGroupId: null, + activeTabIdByGroupId: {} + } + }) + const reloaded = await createStore() + expect(reloaded.getMobileClientTabSelections()['device-a']).toEqual({ + 'other-repo::/tmp/wt': { + activeTabId: 'tab-2', + activeGroupId: null, + activeTabIdByGroupId: {} + } + }) + }) + + it('prunes selections when a folder workspace is removed directly or with its group', async () => { + const store = await createStore() + const directGroup = store.createProjectGroup({ + name: 'Direct', + parentPath: '/tmp/direct', + createdFrom: 'manual' + }) + const directWorkspace = store.createFolderWorkspace({ + projectGroupId: directGroup.id, + name: 'Direct workspace' + }) + const cascadeGroup = store.createProjectGroup({ + name: 'Cascade', + parentPath: '/tmp/cascade', + createdFrom: 'manual' + }) + const cascadeWorkspace = store.createFolderWorkspace({ + projectGroupId: cascadeGroup.id, + name: 'Cascade workspace' + }) + store.setMobileClientTabSelections({ + 'device-a': { + [folderWorkspaceKey(directWorkspace.id)]: { + activeTabId: 'tab-direct', + activeGroupId: null, + activeTabIdByGroupId: {} + }, + [folderWorkspaceKey(cascadeWorkspace.id)]: { + activeTabId: 'tab-cascade', + activeGroupId: null, + activeTabIdByGroupId: {} + } + } + }) + + store.removeFolderWorkspace(directWorkspace.id) + store.deleteProjectGroup(cascadeGroup.id) + store.flush() + + const reloaded = await createStore() + expect(reloaded.getMobileClientTabSelections()).toEqual({}) + }) + }) + describe('claudeLivePtySessionIds', () => { it('persists added ids across reloads and removes them durably', async () => { const store = await createStore() @@ -10360,6 +10464,22 @@ describe('Store.migrateWorktreeIdentity', () => { expect(store.getWorktreeLineage(CHILD)?.parentWorktreeId).toBe(NEW) }) + it('moves persisted mobile selections across reloads', async () => { + const store = await createStore() + store.setMobileClientTabSelections({ + 'device-a': { + [OLD]: { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: {} } + } + }) + + store.migrateWorktreeIdentity(OLD, NEW) + store.flush() + + expect(store.getMobileClientTabSelections()['device-a']?.[OLD]).toBeUndefined() + const reloaded = await createStore() + expect(reloaded.getMobileClientTabSelections()['device-a']?.[NEW]?.activeTabId).toBe('tab-1') + }) + it('accumulates prior ids across chained renames', async () => { const store = await createStore() store.setWorktreeMeta(OLD, { displayName: 'Cunner' }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index ebb0ebacc..3eb828172 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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-persistence' import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement' import { removeRepoFromHostWorkspaceSessions, @@ -3034,6 +3036,9 @@ export class Store { normalizedProjectGroups ), worktreeLineageById: parsed.worktreeLineageById ?? {}, + mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections( + parsed.mobileClientTabSelectionsByDeviceId + ), workspaceLineageByChildKey: normalizeWorkspaceLineageByChildKey( parsed.workspaceLineageByChildKey ), @@ -3910,8 +3915,10 @@ export class Store { ? { ...repo, projectGroupId: null } : repo ) + const removedFolderWorkspaceKeys = new Set() for (const workspace of this.state.folderWorkspaces ?? []) { if (deletedGroupIds.has(workspace.projectGroupId)) { + removedFolderWorkspaceKeys.add(folderWorkspaceKey(workspace.id)) this.state.workspaceSession = removeWorkspaceSessionOwner( this.state.workspaceSession, folderWorkspaceKey(workspace.id) @@ -3922,6 +3929,7 @@ export class Store { this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter( (workspace) => !deletedGroupIds.has(workspace.projectGroupId) ) + this.pruneMobileClientTabSelections((worktreeId) => removedFolderWorkspaceKeys.has(worktreeId)) this.scheduleSave() return true } @@ -4071,6 +4079,7 @@ export class Store { folderWorkspaceKey(id) )! this.removeWorkspaceLineageForFolderParent(id) + this.pruneMobileClientTabSelections((worktreeId) => worktreeId === folderWorkspaceKey(id)) this.scheduleSave() return true } @@ -4249,6 +4258,22 @@ export class Store { delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey] } } + this.pruneMobileClientTabSelections(belongsToHost) + } + + private pruneMobileClientTabSelections(matchesWorktreeId: (worktreeId: string) => boolean): void { + for (const [clientNavigationId, selectionsByWorktree] of Object.entries( + this.state.mobileClientTabSelectionsByDeviceId ?? {} + )) { + for (const worktreeId of Object.keys(selectionsByWorktree)) { + if (matchesWorktreeId(worktreeId)) { + delete selectionsByWorktree[worktreeId] + } + } + if (Object.keys(selectionsByWorktree).length === 0) { + delete this.state.mobileClientTabSelectionsByDeviceId?.[clientNavigationId] + } + } } updateRepo( @@ -4488,6 +4513,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) @@ -5035,6 +5071,11 @@ export class Store { for (const session of Object.values(this.state.workspaceSessionsByHostId ?? {})) { changed = migrateSession(session) || changed } + for (const selectionsByWorktree of Object.values( + this.state.mobileClientTabSelectionsByDeviceId ?? {} + )) { + changed = moveKey(selectionsByWorktree) || changed + } const showDotfiles = this.state.ui?.showDotfilesByWorktree if (showDotfiles) { changed = moveKey(showDotfiles) || changed diff --git a/src/main/runtime/client-session-tab-selection-persistence.ts b/src/main/runtime/client-session-tab-selection-persistence.ts new file mode 100644 index 000000000..358144cb4 --- /dev/null +++ b/src/main/runtime/client-session-tab-selection-persistence.ts @@ -0,0 +1,61 @@ +import type { + PersistedMobileClientTabSelection, + PersistedMobileClientTabSelections +} from '../../shared/types' + +function normalizeClientSessionTabSelection( + raw: unknown +): PersistedMobileClientTabSelection | null { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return null + } + const candidate = raw as Partial + const activeTabId = typeof candidate.activeTabId === 'string' ? candidate.activeTabId : null + const activeGroupId = typeof candidate.activeGroupId === 'string' ? candidate.activeGroupId : null + const activeTabIdByGroupId: Record = {} + if ( + typeof candidate.activeTabIdByGroupId === 'object' && + candidate.activeTabIdByGroupId && + !Array.isArray(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 || Array.isArray(raw)) { + return normalized + } + for (const [clientNavigationId, selectionsByWorktree] of Object.entries(raw)) { + if ( + typeof selectionsByWorktree !== 'object' || + selectionsByWorktree === null || + Array.isArray(selectionsByWorktree) + ) { + continue + } + const entries: Record = {} + 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 +} diff --git a/src/main/runtime/client-session-tab-selection.test.ts b/src/main/runtime/client-session-tab-selection.test.ts index c75ffd2ac..b0db68c5c 100644 --- a/src/main/runtime/client-session-tab-selection.test.ts +++ b/src/main/runtime/client-session-tab-selection.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import type { PersistedMobileClientTabSelections } from '../../shared/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 = [ @@ -138,4 +140,147 @@ 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('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('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: {} } + } + }) + }) }) diff --git a/src/main/runtime/client-session-tab-selection.ts b/src/main/runtime/client-session-tab-selection.ts index d3cb60e67..7c8f84f88 100644 --- a/src/main/runtime/client-session-tab-selection.ts +++ b/src/main/runtime/client-session-tab-selection.ts @@ -2,6 +2,8 @@ import type { RuntimeMobileSessionClientTab, RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import type { PersistedMobileClientTabSelections } from '../../shared/types' +import { normalizePersistedMobileClientTabSelections } from './client-session-tab-selection-persistence' export type ClientSessionTabSelection = { activeTabId: string | null @@ -12,6 +14,8 @@ export type ClientSessionTabSelection = { type StoredClientSessionTabSelection = { selection: ClientSessionTabSelection revision: number + // Why: listAll projects every worktree; only hydrated or user-activated selections belong on disk. + shouldPersist: boolean } function emptyClientSessionTabSelection(): ClientSessionTabSelection { @@ -126,6 +130,43 @@ export function projectClientSessionTabSelection( export class ClientSessionTabSelectionStore { private statesByClient = new Map>() + 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, shouldPersist: true }) + } + } + } + + setPersistListener(listener: (state: PersistedMobileClientTabSelections) => void): void { + this.persistListener = listener + } + + serialize(): PersistedMobileClientTabSelections { + const persisted: PersistedMobileClientTabSelections = {} + for (const [clientNavigationId, statesByWorktree] of this.statesByClient) { + const entries: Record = {} + for (const [worktreeId, state] of statesByWorktree) { + if (state.shouldPersist) { + 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 @@ -149,12 +190,22 @@ export class ClientSessionTabSelectionStore { const state = statesByWorktree.get(snapshot.worktree) ?? { // Why: host focus is private navigation; a new paired device starts from deterministic topology instead of inheriting it. selection: emptyClientSessionTabSelection(), - revision: 0 + revision: 0, + shouldPersist: false + } + 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, - revision: state.revision + revision: state.revision, + shouldPersist: state.shouldPersist }) return { ...projected.snapshot, @@ -171,25 +222,60 @@ export class ClientSessionTabSelectionStore { const statesByWorktree = this.getStatesByWorktree(clientNavigationId) const state = statesByWorktree.get(snapshot.worktree) ?? { selection: emptyClientSessionTabSelection(), - revision: 0 + revision: 0, + shouldPersist: false } + const nextSelection = activateClientSessionTabSelection(snapshot, state.selection, activeTabId) statesByWorktree.set(snapshot.worktree, { - selection: activateClientSessionTabSelection(snapshot, state.selection, activeTabId), - revision: state.revision + 1 + selection: nextSelection, + revision: state.revision + 1, + shouldPersist: true }) + this.persistNow() return this.project(snapshot, clientNavigationId) } forgetClient(clientNavigationId: string): void { - this.statesByClient.delete(clientNavigationId) + const statesByWorktree = this.statesByClient.get(clientNavigationId) + const hadPersistedState = [...(statesByWorktree?.values() ?? [])].some( + (state) => state.shouldPersist + ) + if (this.statesByClient.delete(clientNavigationId) && hadPersistedState) { + this.persistNow() + } + } + + migrateWorktree(oldWorktreeId: string, newWorktreeId: string): void { + if (oldWorktreeId === newWorktreeId) { + return + } + let changed = false + for (const statesByWorktree of this.statesByClient.values()) { + const state = statesByWorktree.get(oldWorktreeId) + if (!state) { + continue + } + statesByWorktree.set(newWorktreeId, state) + statesByWorktree.delete(oldWorktreeId) + changed = state.shouldPersist || changed + } + if (changed) { + this.persistNow() + } } forgetWorktree(worktreeId: string): void { + let changed = false for (const [clientNavigationId, statesByWorktree] of this.statesByClient) { + const state = statesByWorktree.get(worktreeId) + changed = Boolean(state?.shouldPersist) || changed statesByWorktree.delete(worktreeId) if (statesByWorktree.size === 0) { this.statesByClient.delete(clientNavigationId) } } + if (changed) { + this.persistNow() + } } } diff --git a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts index 3c918f7b4..d475d6aa0 100644 --- a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts +++ b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts @@ -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') + }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0706ec19b..5c25ce817 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -952,6 +952,8 @@ type RuntimeStore = { deleteAutomation?: Store['deleteAutomation'] getSparsePresets?: Store['getSparsePresets'] saveSparsePreset?: Store['saveSparsePreset'] + getMobileClientTabSelections?: Store['getMobileClientTabSelections'] + setMobileClientTabSelections?: Store['setMobileClientTabSelections'] getSettings(): { workspaceDir: string nestWorkspaces: boolean @@ -2879,6 +2881,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) @@ -23946,6 +23956,7 @@ export class OrcaRuntimeService { /** Like {@link notifyBranchRenamed} but carries old->new worktree id so the renderer re-keys instead of treating the id change as a deletion. */ notifyWorktreeFolderRenamed(repoId: string, oldWorktreeId: string, newWorktreeId: string): void { + this.clientSessionTabSelections.migrateWorktree(oldWorktreeId, newWorktreeId) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(repoId) this.notifier?.worktreesChanged(repoId, { oldWorktreeId, newWorktreeId }) diff --git a/src/shared/types.ts b/src/shared/types.ts index 437f92d65..bc68a3375 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3419,6 +3419,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> +} + +/** deviceId → worktreeId → selection. */ +export type PersistedMobileClientTabSelections = Record< + string, + Record +> + // ─── Persistence shape ────────────────────────────────────────────── export type PersistedState = { schemaVersion: number @@ -3429,6 +3442,8 @@ export type PersistedState = { folderWorkspaces: FolderWorkspace[] /** Sparse-checkout presets keyed by repoId. */ sparsePresetsByRepo: Record + /** Per paired device last tab selection by worktree; keeps mobile navigation across host restarts. */ + mobileClientTabSelectionsByDeviceId?: PersistedMobileClientTabSelections worktreeMeta: Record worktreeLineageById: Record workspaceLineageByChildKey: Record