fix(repos): keep the desktop-owned manual project order authoritative (STA-4850)

The user's manual project order was lost two ways.

A paired client keys its manual order to its own execution hosts — a web
client restamps every repo onto runtime:web-<uuid> — and ui.set forwarded
that array to the host, where updatePersistedUI replaces ui.manualRepoOrder
wholesale. The desktop's local/ssh-keyed order was destroyed and the scramble
surfaced at the next cold start. Give the desktop profile sole ownership:
ui.set strips manualRepoOrder on the host, and the web client stops sending it
so old hosts are protected too. Paired clients keep their own host-local
permutation via repo.reorder. The field stays in the strict UiUpdate schema —
removing it would reject an old client's entire payload.

A direct-SSH catalog publish re-appended the host's rows at the tail of the
repos array without re-applying the overlay, sinking SSH repos on every
connect including the first after launch. mergeExactHostCatalog now applies
applyManualRepoOrder to the merged array.

Cross-host project grouping (#13986) is unchanged and tracked separately.

Fixes #15468
This commit is contained in:
Jinwoo-H
2026-08-19 18:39:40 -04:00
parent 9b8dbd8930
commit 0735811b38
6 changed files with 114 additions and 8 deletions
+25 -1
View File
@@ -560,13 +560,37 @@ describe('client UI RPC methods', () => {
}
const response = await dispatcher.dispatch(makeRequest('ui.set', payload))
const { manualRepoOrder: _desktopOwnedOrder, ...forwarded } = payload
expect(runtime.updateUIState).toHaveBeenCalledWith({
...payload,
...forwarded,
worktreeCardProperties: ['status', 'unread', 'branch', 'automation', 'inline-agents']
})
expect(response).toMatchObject({ ok: true, result: { ui: updated } })
})
it('drops a paired client manualRepoOrder while forwarding the rest of the payload', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
updateUIState: vi.fn(() => getDefaultUIState())
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS })
// A paired web client restamps every repo onto its own runtime:web-* pseudo-host, so its
// overlay names hosts this profile will never own; persisting it erases the desktop order.
const response = await dispatcher.dispatch(
makeRequest('ui.set', {
sidebarWidth: 280,
manualRepoOrder: [
{ hostId: 'runtime:web-11111111-2222-3333-4444-555555555555', repoId: 'repo-a' },
{ hostId: 'runtime:web-11111111-2222-3333-4444-555555555555', repoId: 'repo-b' }
]
})
)
expect(response).toMatchObject({ ok: true })
expect(runtime.updateUIState).toHaveBeenCalledWith({ sidebarWidth: 280 })
})
// Why one case per field: the schema is strict, so a single unlisted key makes
// the dispatcher reject the ENTIRE ui.set payload with invalid_argument instead
// of stripping it. A combined payload would pass as soon as any one field were
+9 -1
View File
@@ -55,9 +55,17 @@ export const CLIENT_UI_METHODS: RpcMethod[] = [
defineMethod({
name: 'ui.set',
params: UiUpdate,
// Why manualRepoOrder is dropped rather than removed from the schema: a paired client keys
// its overlay to its own execution hosts, so forwarding it blind-replaces the desktop's
// order. The strict schema still has to accept the field or old clients' whole payload fails.
handler: (params, { runtime }) => {
const { hideWorkspacesFromOtherDevices: _clientLocalFilter, ...hostUpdates } = params
const {
hideWorkspacesFromOtherDevices: _clientLocalFilter,
manualRepoOrder: _desktopOwnedOrder,
...hostUpdates
} = params
void _clientLocalFilter
void _desktopOwnedOrder
return { ui: runtime.updateUIState(hostUpdates as Partial<PersistedUIState>) }
}
}),
@@ -113,6 +113,32 @@ describe('createDirectSshHostHydration', () => {
])
})
it('keeps the manual cross-host order when the host catalog is republished', async () => {
const owner = authority('box')
const store = createStore<AppState>(() =>
state({
repos: [repo('bravo', 'box'), repo('alpha', null), repo('delta', 'box')],
manualRepoOrder: [
{ hostId: 'ssh:box', repoId: 'bravo' },
{ hostId: 'local', repoId: 'alpha' },
{ hostId: 'ssh:box', repoId: 'delta' }
]
})
)
const hydration = createDirectSshHostHydration({
store,
listRepos: vi.fn(async () =>
hostSnapshot(owner, [repo('bravo', 'box'), repo('delta', 'box')])
),
listLineage: vi.fn(),
isCurrentAuthority: () => true
})
await hydration.capturePreparationInput(owner, 'reconnect')
expect(store.getState().repos.map((entry) => entry.id)).toEqual(['bravo', 'alpha', 'delta'])
})
it('rejects a mismatched host response without publishing', async () => {
const owner = authority()
const store = createStore<AppState>(() => state({ repos: [repo('cached', 'target-a')] }))
@@ -2,6 +2,7 @@ import type { StoreApi } from 'zustand'
import { getRepoExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host'
import type { HostLineageSnapshot } from '../../../shared/host-lineage-contract'
import type { HostRepoCatalogSnapshot } from '../../../shared/host-repo-catalog-contract'
import { applyManualRepoOrder } from '../../../shared/manual-repo-order'
import type { DirectSshAuthority } from '../../../shared/ssh-types'
import { isWorkspaceKey } from '../../../shared/workspace-scope'
import type { AppState } from '../store/types'
@@ -63,12 +64,14 @@ function mergeExactHostCatalog(state: AppState, snapshot: HostRepoCatalogSnapsho
return state
}
const hostId = snapshot.authority.executionHostId
// Why re-apply the overlay: re-appending the host's rows puts them at the tail, which would
// undo the user's manual cross-host order on every connect.
return {
...state,
repos: [
...state.repos.filter((repo) => getRepoExecutionHostId(repo) !== hostId),
...snapshot.repos
]
repos: applyManualRepoOrder(
[...state.repos.filter((repo) => getRepoExecutionHostId(repo) !== hostId), ...snapshot.repos],
state.manualRepoOrder
)
}
}
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FeatureInteractionState } from '../../../shared/feature-interactions'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import type { ManualRepoOrderEntry } from '../../../shared/ui-chrome-types'
import {
installApi,
installBrowserGlobals,
@@ -327,6 +328,44 @@ describe('web UI preload API', () => {
})
})
it('keeps the manual repo order browser-local instead of overwriting the host order', async () => {
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: {} },
_meta: { runtimeId: 'runtime-1' }
})
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
// The web client keys its order to its own runtime:web-* host, so a host that predates the
// RPC-side strip would persist it over the desktop's local/ssh-keyed order.
const manualRepoOrder: ManualRepoOrderEntry[] = [
{ hostId: 'runtime:web-11111111-2222-3333-4444-555555555555', repoId: 'repo-b' },
{ hostId: 'runtime:web-11111111-2222-3333-4444-555555555555', repoId: 'repo-a' }
]
await globals.window.api.ui.set({ manualRepoOrder, sidebarWidth: 280 })
expect(runtimeCalls[0]).toEqual({ method: 'ui.set', params: { sidebarWidth: 280 } })
expect(JSON.parse(globals.storage.getItem('orca.web.ui.v1') ?? '{}')).toMatchObject({
manualRepoOrder,
sidebarWidth: 280
})
})
it('union-merges local contextual tour seen ids when ui.get returns stale host state', async () => {
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
+8 -2
View File
@@ -2731,9 +2731,15 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
const next = mergeWebUIState(readLocalWebUIState(), updates)
writeJson(UI_STORAGE_KEY, next)
zoomLevel = next.uiZoomLevel
const { hideWorkspacesFromOtherDevices: _clientLocalWorkspaceFilter, ...hostUpdates } =
updates
// Why manualRepoOrder stays local: it is keyed to this client's runtime:web-* hosts, so
// sending it would overwrite the desktop profile's order on hosts that predate the strip.
const {
hideWorkspacesFromOtherDevices: _clientLocalWorkspaceFilter,
manualRepoOrder: _desktopOwnedOrder,
...hostUpdates
} = updates
void _clientLocalWorkspaceFilter
void _desktopOwnedOrder
try {
await callRuntimeResult('ui.set', hostUpdates, 15_000)
} catch {