diff --git a/src/main/persistence-ui-state.test.ts b/src/main/persistence-ui-state.test.ts index 738fd189eb1..ccafa4e3fab 100644 --- a/src/main/persistence-ui-state.test.ts +++ b/src/main/persistence-ui-state.test.ts @@ -98,13 +98,16 @@ describe('Store', () => { // The RPC now strips manualRepoOrder, so every paired-client ui.set reaches the store without // the key. Absent has to mean preserve: if it read as clear, the strip would erase the desktop's // order on the first unrelated setting a phone or web client changes. - it('updateUI preserves the manual repo order when an update omits it', async () => { + // Absent-means-preserve is what makes the pairing-local strip safe: a client's ui.set arrives + // without these fields, so the desktop's own values must survive the update. + it('updateUI preserves the manual repo and host-section order when an update omits them', async () => { const store = await createStore() store.updateUI({ manualRepoOrder: [ { hostId: 'local', repoId: 'alpha' }, { hostId: 'ssh:box', repoId: 'bravo' } - ] as never + ] as never, + workspaceHostOrder: ['ssh:box', 'local'] as never }) store.updateUI({ sidebarWidth: 400 }) @@ -113,6 +116,7 @@ describe('Store', () => { { hostId: 'local', repoId: 'alpha' }, { hostId: 'ssh:box', repoId: 'bravo' } ]) + expect(store.getUI().workspaceHostOrder).toEqual(['ssh:box', 'local']) expect(store.getUI().sidebarWidth).toBe(400) }) diff --git a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts index 34a2bc68742..d2bcec6a7f3 100644 --- a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts +++ b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts @@ -42,7 +42,8 @@ describe('client UI RPC pairing-local field seams', () => { hideWorkspacesFromOtherDevices: true, manualRepoOrder: [ { hostId: 'runtime:web-11111111-2222-3333-4444-555555555555', repoId: 'repo-a' } - ] + ], + workspaceHostOrder: ['runtime:web-11111111-2222-3333-4444-555555555555', 'local'] } it.each(PAIRING_LOCAL_UI_FIELDS.map((field) => [field] as const))( diff --git a/src/renderer/src/web/web-preload-api-ui.test.ts b/src/renderer/src/web/web-preload-api-ui.test.ts index 8191a31f991..ce5e9572f9b 100644 --- a/src/renderer/src/web/web-preload-api-ui.test.ts +++ b/src/renderer/src/web/web-preload-api-ui.test.ts @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { FeatureInteractionState } from '../../../shared/feature-interactions' +import { + PAIRING_LOCAL_UI_FIELDS, + type PairingLocalUiField +} from '../../../shared/pairing-local-ui-fields' +import type { PersistedUIState } from '../../../shared/persisted-ui-state-types' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { ManualRepoOrderEntry } from '../../../shared/ui-chrome-types' import { @@ -452,6 +457,57 @@ describe('web UI preload API', () => { }) }) + // Census-driven, matching the host-side seam tests: a field added to PAIRING_LOCAL_UI_FIELDS + // without wiring the web read seam fails here rather than shipping. The host sample differs from + // the browser's for every field, so only the pin makes this pass. + const browserLocalUiSamples: Record = { + hideWorkspacesFromOtherDevices: true, + manualRepoOrder: [{ hostId: 'runtime:web-env-1', repoId: 'repo-b' }], + workspaceHostOrder: ['runtime:web-env-1', 'local'] + } + const hostUiSamples: Record = { + hideWorkspacesFromOtherDevices: false, + manualRepoOrder: [{ hostId: 'local', repoId: 'repo-a' }], + workspaceHostOrder: ['local', 'ssh:box'] + } + + it.each(PAIRING_LOCAL_UI_FIELDS.map((field) => [field] as const))( + 'keeps the browser-local %s and never sends it to the host', + async (field) => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: method, + ok: true, + result: { ui: { [field]: hostUiSamples[field] } }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const browserLocal = { [field]: browserLocalUiSamples[field] } as Partial + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + globals.storage.setItem('orca.web.ui.v1', JSON.stringify(browserLocal)) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await globals.window.api.ui.set({ ...browserLocal, sidebarWidth: 280 }) + + expect(runtimeCalls[0]).toEqual({ method: 'ui.set', params: { sidebarWidth: 280 } }) + await expect(globals.window.api.ui.get()).resolves.toMatchObject(browserLocal) + expect(JSON.parse(globals.storage.getItem('orca.web.ui.v1') ?? '{}')).toMatchObject( + browserLocal + ) + } + ) + it('union-merges local contextual tour seen ids when ui.get returns stale host state', async () => { vi.doMock('./web-runtime-client', () => ({ WebRuntimeClient: class { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 4bcd94167b1..894ce0157dc 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -34,6 +34,7 @@ import type { OnboardingState } from '../../../shared/onboarding-state-types' import type { PersistedUIState } from '../../../shared/persisted-ui-state-types' import { omitPairingLocalUiFields, + type PairedUiState, type PairingLocalUiField } from '../../../shared/pairing-local-ui-fields' import type { MemorySnapshot, StatsSummary } from '../../../shared/process-stats-types' @@ -2706,11 +2707,7 @@ function createWebUiApi(): NonNullable['ui']> { return { get: async () => { try { - const result = await callRuntimeResult<{ ui: PersistedUIState }>( - 'ui.get', - undefined, - 15_000 - ) + const result = await callRuntimeResult<{ ui: PairedUiState }>('ui.get', undefined, 15_000) const local = readLocalWebUIState() const next = { ...mergeHostWebUIState(local, result.ui), @@ -2759,7 +2756,7 @@ function createWebUiApi(): NonNullable['ui']> { }) writeJson(UI_STORAGE_KEY, optimistic) try { - const result = await callRuntimeResult<{ ui: PersistedUIState }>( + const result = await callRuntimeResult<{ ui: PairedUiState }>( 'ui.recordFeatureInteraction', id, 15_000 @@ -4173,14 +4170,14 @@ function mergeWebUIState( // Why pin instead of trusting the host's strip: an old host still returns these fields, and for a // profile a pre-fix drag poisoned they echo back the same runtime:web-* keys this browser minted, // which then match its own repos and beat every newer drag. -function mergeHostWebUIState( - local: PersistedUIState, - incoming: PersistedUIState -): PersistedUIState { - const pinned: Pick = { +function mergeHostWebUIState(local: PersistedUIState, incoming: PairedUiState): PersistedUIState { + // Why `satisfies Record<...>` rather than a `Pick<...>` annotation: every member is optional in + // PersistedUIState, so Pick would accept a literal that silently skipped a newly added member. + const pinned = { hideWorkspacesFromOtherDevices: local.hideWorkspacesFromOtherDevices === true, - manualRepoOrder: local.manualRepoOrder - } + manualRepoOrder: local.manualRepoOrder, + workspaceHostOrder: local.workspaceHostOrder + } satisfies Record & Partial return { ...mergeWebUIState(local, incoming), ...pinned } } diff --git a/src/shared/pairing-local-ui-fields.test.ts b/src/shared/pairing-local-ui-fields.test.ts index 190b12aa10e..41545abb378 100644 --- a/src/shared/pairing-local-ui-fields.test.ts +++ b/src/shared/pairing-local-ui-fields.test.ts @@ -7,7 +7,8 @@ describe('pairing-local UI fields', () => { it('census: the set is exactly the fields no pairing may exchange', () => { expect([...PAIRING_LOCAL_UI_FIELDS]).toEqual([ 'hideWorkspacesFromOtherDevices', - 'manualRepoOrder' + 'manualRepoOrder', + 'workspaceHostOrder' ]) }) @@ -15,6 +16,7 @@ describe('pairing-local UI fields', () => { const state = { hideWorkspacesFromOtherDevices: true, manualRepoOrder: [{ hostId: 'local' as const, repoId: 'repo-a' }], + workspaceHostOrder: ['local' as const], sidebarWidth: 280, activeView: 'tasks' as const } diff --git a/src/shared/pairing-local-ui-fields.ts b/src/shared/pairing-local-ui-fields.ts index fb7d9b4042a..453a16eb0ee 100644 --- a/src/shared/pairing-local-ui-fields.ts +++ b/src/shared/pairing-local-ui-fields.ts @@ -1,17 +1,22 @@ import type { PersistedUIState } from './persisted-ui-state-types' // UI state each side of a pairing owns for itself. These fields describe a client's own view — -// which workspaces it hides, what order it puts repos in — and are keyed to hosts only that side -// knows about, so a value copied across the boundary in either direction overwrites the receiver's -// answer with one computed for somebody else. They must be stripped on the way to a host (ui.set) -// and pinned to the local value on the way back (ui.get). +// which workspaces it hides, what order it puts repos and host sections in — and are keyed to hosts +// only that side knows about, so a value copied across the boundary in either direction overwrites +// the receiver's answer with one computed for somebody else. They must be stripped on the way to a +// host (ui.set) and pinned to the local value on the way back (ui.get). export const PAIRING_LOCAL_UI_FIELDS = [ 'hideWorkspacesFromOtherDevices', - 'manualRepoOrder' + 'manualRepoOrder', + 'workspaceHostOrder' ] as const satisfies readonly (keyof PersistedUIState)[] export type PairingLocalUiField = (typeof PAIRING_LOCAL_UI_FIELDS)[number] +// What a paired client actually receives over the UI RPCs, so reading a pairing-local field off a +// host response is a compile error rather than a silent undefined. +export type PairedUiState = Omit + const PAIRING_LOCAL_UI_FIELD_SET: ReadonlySet = new Set(PAIRING_LOCAL_UI_FIELDS) // Accepts any object, not just Partial: the ui.set seam passes the zod-inferred