mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
fix(mobile): preserve tab selection across worktree rename
This commit is contained in:
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<PersistedMobileClientTabSelection>
|
||||
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 &&
|
||||
!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<string, PersistedMobileClientTabSelection> = {}
|
||||
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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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<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 &&
|
||||
!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<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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -30,7 +30,10 @@ export type AgentBackgroundSessionTestState = {
|
||||
{ id: string; repoId: string; projectId: string; path: string; displayName: string }[]
|
||||
>
|
||||
tabsByWorktree: Record<string, { id: string; title: string }[]>
|
||||
terminalLayoutsByTabId: Record<string, { ptyIdsByLeafId?: Record<string, string | undefined> }>
|
||||
terminalLayoutsByTabId: Record<
|
||||
string,
|
||||
{ ptyIdsByLeafId?: Record<string, string | undefined> }
|
||||
>
|
||||
ptyIdsByTabId: Record<string, string[]>
|
||||
sshConnectionStates: Map<string, { status: string }>
|
||||
transientClearedAgentStatusConnectionIds: Record<string, true>
|
||||
|
||||
Reference in New Issue
Block a user