From 58c6cb636dbcfa30f1eb0f251a8b3714f2cef4ba Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:48:11 -0700 Subject: [PATCH] fix(mobile): preserve tab selection across worktree rename --- src/main/persistence.test.ts | 16 ++++ src/main/persistence.ts | 7 +- ...lient-session-tab-selection-persistence.ts | 61 +++++++++++++++ .../client-session-tab-selection.test.ts | 29 ++++++- .../runtime/client-session-tab-selection.ts | 75 +++++-------------- src/main/runtime/orca-runtime.ts | 1 + .../agent-background-session-test-state.ts | 5 +- 7 files changed, 136 insertions(+), 58 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 d2f9f64aee4..5124b34c2da 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -10464,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 1ac59d6547c..3eb82817212 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -78,7 +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 { normalizePersistedMobileClientTabSelections } from './runtime/client-session-tab-selection-persistence' import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement' import { removeRepoFromHostWorkspaceSessions, @@ -5071,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 00000000000..358144cb49d --- /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 b28ec6a8897..b0db68c5ca5 100644 --- a/src/main/runtime/client-session-tab-selection.test.ts +++ b/src/main/runtime/client-session-tab-selection.test.ts @@ -5,9 +5,9 @@ import { activateClientSessionTabSelection, ClientSessionTabSelectionStore, deriveClientSessionTabSelection, - normalizePersistedMobileClientTabSelections, projectClientSessionTabSelection } from './client-session-tab-selection' +import { normalizePersistedMobileClientTabSelections } from './client-session-tab-selection-persistence' function snapshot(activeTabId = 'terminal-a::leaf-a'): RuntimeMobileSessionTabsResult { const tabs = [ @@ -179,6 +179,33 @@ describe('client session-tab selection', () => { 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() diff --git a/src/main/runtime/client-session-tab-selection.ts b/src/main/runtime/client-session-tab-selection.ts index 8605cdfe4a1..7c8f84f88d2 100644 --- a/src/main/runtime/client-session-tab-selection.ts +++ b/src/main/runtime/client-session-tab-selection.ts @@ -3,6 +3,7 @@ import type { 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 @@ -127,61 +128,6 @@ export function projectClientSessionTabSelection( } } -function normalizeClientSessionTabSelection(raw: unknown): ClientSessionTabSelection | 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 -} - export class ClientSessionTabSelectionStore { private statesByClient = new Map>() private persistListener: ((state: PersistedMobileClientTabSelections) => void) | null = null @@ -299,6 +245,25 @@ export class ClientSessionTabSelectionStore { } } + 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) { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index cd6108f504e..5c25ce81792 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -23956,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/renderer/src/lib/agent-background-session-test-state.ts b/src/renderer/src/lib/agent-background-session-test-state.ts index b338493c411..fae70ea4e3c 100644 --- a/src/renderer/src/lib/agent-background-session-test-state.ts +++ b/src/renderer/src/lib/agent-background-session-test-state.ts @@ -30,7 +30,10 @@ export type AgentBackgroundSessionTestState = { { id: string; repoId: string; projectId: string; path: string; displayName: string }[] > tabsByWorktree: Record - terminalLayoutsByTabId: Record }> + terminalLayoutsByTabId: Record< + string, + { ptyIdsByLeafId?: Record } + > ptyIdsByTabId: Record sshConnectionStates: Map transientClearedAgentStatusConnectionIds: Record