diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index a1f24c28a9f..15c2def486e 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 70ad9e9df57..2096843d355 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -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) } } }), diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts b/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts index 4aa654cf33a..fba5db317f8 100644 --- a/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts +++ b/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts @@ -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(() => + 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(() => state({ repos: [repo('cached', 'target-a')] })) diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration.ts b/src/renderer/src/hooks/direct-ssh-host-hydration.ts index 069b3b3d323..419dc134075 100644 --- a/src/renderer/src/hooks/direct-ssh-host-hydration.ts +++ b/src/renderer/src/hooks/direct-ssh-host-hydration.ts @@ -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 + ) } } 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 e809210aac1..15ac7f60c16 100644 --- a/src/renderer/src/web/web-preload-api-ui.test.ts +++ b/src/renderer/src/web/web-preload-api-ui.test.ts @@ -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> { + 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 { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 57f5efacef3..3b191594d33 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2731,9 +2731,15 @@ function createWebUiApi(): NonNullable['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 {