mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user