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