fix(repos): keep the host section order pairing-local too (STA-4850)

workspaceHostOrder crosses the pairing boundary exactly like manualRepoOrder did:
ui.ts:2107 sends it unconditionally, a web client's ids are runtime:web-* keyed, and
updatePersistedUI blind-replaces, so the desktop's host-section order silently resets
on the next cold start. Add it to PAIRING_LOCAL_UI_FIELDS so both seams cover it.

Make the web read seam census-enforced: the pinned literal is now constrained by
Record<PairingLocalUiField, unknown>, so a member added to the set without wiring
mergeHostWebUIState fails to compile as well as failing a named test on that seam.
This commit is contained in:
Jinwoo-H
2026-08-19 19:32:44 -04:00
parent c46fbcf9c4
commit 24008c480b
6 changed files with 87 additions and 22 deletions
+6 -2
View File
@@ -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)
})
@@ -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))(
@@ -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<PairingLocalUiField, unknown> = {
hideWorkspacesFromOtherDevices: true,
manualRepoOrder: [{ hostId: 'runtime:web-env-1', repoId: 'repo-b' }],
workspaceHostOrder: ['runtime:web-env-1', 'local']
}
const hostUiSamples: Record<PairingLocalUiField, unknown> = {
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<RuntimeRpcResponse<unknown>> {
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<PersistedUIState>
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 {
+10 -13
View File
@@ -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<Partial<PreloadApi>['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<Partial<PreloadApi>['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<PersistedUIState, PairingLocalUiField> = {
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<PairingLocalUiField, unknown> & Partial<PersistedUIState>
return { ...mergeWebUIState(local, incoming), ...pinned }
}
+3 -1
View File
@@ -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
}
+10 -5
View File
@@ -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<PersistedUIState, PairingLocalUiField>
const PAIRING_LOCAL_UI_FIELD_SET: ReadonlySet<string> = new Set(PAIRING_LOCAL_UI_FIELDS)
// Accepts any object, not just Partial<PersistedUIState>: the ui.set seam passes the zod-inferred