Fix manual project order across paired runtime hosts (#8894)

* fix(sidebar): persist manual project order across hosts

Co-authored-by: Orca <help@stably.ai>

* fix(sidebar): scope desktop repo reorder by host

Co-authored-by: Orca <help@stably.ai>

* fix(web): cover host-scoped repo reorder API

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-07-15 13:32:13 -07:00 committed by GitHub
parent 45a772cb42
commit a2d3451efd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 631 additions and 31 deletions

View File

@ -1127,6 +1127,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.removeHandler('repos:remove')
ipcMain.removeHandler('repos:removeForHost')
ipcMain.removeHandler('repos:reorder')
ipcMain.removeHandler('repos:reorderForHost')
ipcMain.removeHandler('repos:update')
ipcMain.removeHandler('projects:list')
ipcMain.removeHandler('projects:update')
@ -1928,6 +1929,26 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
)
ipcMain.handle(
'repos:reorderForHost',
(
_event,
args: { orderedIds: string[]; hostId: string }
): { status: 'applied' | 'rejected' } => {
const hostId = normalizeExecutionHostId(args?.hostId)
if (!hostId) {
return { status: 'rejected' }
}
const ids = Array.isArray(args?.orderedIds) ? args.orderedIds : []
const applied = store.reorderReposForHost(ids, hostId)
if (applied) {
notifyReposChanged(mainWindow)
return { status: 'applied' }
}
return { status: 'rejected' }
}
)
ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => {
store.removeProject(args.repoId)
invalidateAuthorizedRootsCache()

View File

@ -3317,6 +3317,62 @@ describe('Store', () => {
expect(store.getWorktreeMeta('shared::/remote/repo/wt')).toBeUndefined()
})
it('reorderReposForHost independently reorders local and SSH rows with shared ids', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'shared', path: '/local/shared' }))
store.addRepo(
makeRepo({
id: 'shared',
path: '/ssh/shared',
connectionId: 'target'
})
)
store.addRepo(makeRepo({ id: 'local-two', path: '/local/two' }))
store.addRepo(
makeRepo({
id: 'ssh-two',
path: '/ssh/two',
connectionId: 'target'
})
)
expect(store.reorderReposForHost(['local-two', 'shared'], 'local')).toBe(true)
expect(store.getRepos().map((repo) => repo.path)).toEqual([
'/local/two',
'/ssh/shared',
'/local/shared',
'/ssh/two'
])
expect(store.reorderReposForHost(['ssh-two', 'shared'], 'ssh:target')).toBe(true)
expect(store.getRepos().map((repo) => repo.path)).toEqual([
'/local/two',
'/ssh/two',
'/local/shared',
'/ssh/shared'
])
})
it('reorderReposForHost rejects stale or duplicate host permutations without mutation', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'local-one', path: '/local/one' }))
store.addRepo(makeRepo({ id: 'local-two', path: '/local/two' }))
store.addRepo(
makeRepo({
id: 'ssh-one',
path: '/ssh/one',
connectionId: 'target',
executionHostId: 'ssh:target'
})
)
const originalPaths = store.getRepos().map((repo) => repo.path)
expect(store.reorderReposForHost(['local-two'], 'local')).toBe(false)
expect(store.reorderReposForHost(['local-one', 'local-one'], 'local')).toBe(false)
expect(store.reorderReposForHost(['missing', 'local-two'], 'local')).toBe(false)
expect(store.getRepos().map((repo) => repo.path)).toEqual(originalPaths)
})
it('removeProjectForHost prunes the SSH host meta (tagged hostId) for a shared id', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'shared', path: '/local/repo' }))
@ -5298,6 +5354,25 @@ describe('Store', () => {
expect(ui.dismissedUpdateVersion).toBeNull()
})
it('round-trips and normalizes the host-qualified manual repo order', async () => {
const store = await createStore()
store.updateUI({
manualRepoOrder: [
{ hostId: 'runtime:node-b', repoId: 'shared' },
{ hostId: 'bogus', repoId: 'ignored' },
{ hostId: 'runtime:node-b', repoId: 'shared' },
{ hostId: 'local', repoId: 'alpha' }
] as never
})
store.flush()
const reloaded = await createStore()
expect(reloaded.getUI().manualRepoOrder).toEqual([
{ hostId: 'runtime:node-b', repoId: 'shared' },
{ hostId: 'local', repoId: 'alpha' }
])
})
it('updateUI persists sanitized per-worktree dotfile visibility', async () => {
const store = await createStore()
store.updateUI({

View File

@ -172,6 +172,7 @@ import {
} from '../shared/feature-interactions'
import { normalizeContextualTourIds } from '../shared/contextual-tours'
import { normalizeFeatureTipIds } from '../shared/feature-tips'
import { normalizeManualRepoOrder } from '../shared/manual-repo-order'
import {
DEFAULT_WORKSPACE_STATUS_ID,
clampWorkspaceBoardColumnWidth,
@ -4270,6 +4271,37 @@ export class Store {
return true
}
// Why: repo ids are unique only within an execution host, and renderer drags
// persist one complete permutation per host when local and SSH repos coexist.
reorderReposForHost(orderedIds: string[], hostId: ExecutionHostId): boolean {
const current = this.state.repos
const hostRepos = current.filter((repo) => getRepoExecutionHostId(repo) === hostId)
if (orderedIds.length !== hostRepos.length) {
return false
}
const byId = new Map(hostRepos.map((repo) => [repo.id, repo]))
if (byId.size !== hostRepos.length) {
return false
}
const seen = new Set<string>()
const reorderedHostRepos: Repo[] = []
for (const id of orderedIds) {
const repo = typeof id === 'string' && !seen.has(id) ? byId.get(id) : undefined
if (!repo) {
return false
}
seen.add(id)
reorderedHostRepos.push(repo)
}
let nextHostIndex = 0
this.state.repos = current.map((repo) =>
getRepoExecutionHostId(repo) === hostId ? reorderedHostRepos[nextHostIndex++] : repo
)
this.syncProjectHostSetupCompatibilityState()
this.scheduleSave()
return true
}
removeProject(id: string): void {
this.state.repos = this.state.repos.filter((r) => r.id !== id)
this.syncProjectHostSetupCompatibilityState()
@ -5409,6 +5441,7 @@ export class Store {
this.state.ui?.visibleWorkspaceHostIds
),
workspaceHostOrder: normalizeExecutionHostOrder(this.state.ui?.workspaceHostOrder),
manualRepoOrder: normalizeManualRepoOrder(this.state.ui?.manualRepoOrder),
browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(
this.state.ui?.browserDefaultZoomLevel
),
@ -5494,6 +5527,10 @@ export class Store {
updates.workspaceHostOrder !== undefined
? normalizeExecutionHostOrder(updates.workspaceHostOrder)
: normalizeExecutionHostOrder(this.state.ui?.workspaceHostOrder),
manualRepoOrder:
updates.manualRepoOrder !== undefined
? normalizeManualRepoOrder(updates.manualRepoOrder)
: normalizeManualRepoOrder(this.state.ui?.manualRepoOrder),
browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(
updates.browserDefaultZoomLevel ?? this.state.ui?.browserDefaultZoomLevel
),

View File

@ -191,6 +191,9 @@ export const UiUpdate = z
workspaceHostScope: z.string().optional(),
visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(),
workspaceHostOrder: z.array(z.string()).optional(),
manualRepoOrder: z
.array(z.object({ hostId: z.string(), repoId: z.string() }).strict())
.optional(),
hideDefaultBranchWorkspace: z.boolean().optional(),
hideAutomationGeneratedWorkspaces: z.boolean().optional(),
filterRepoIds: StringArray.optional(),

View File

@ -269,7 +269,8 @@ describe('client UI RPC methods', () => {
contextualToursSeenIds: ['tasks'],
contextualToursAutoEligible: true,
usageEmptyStateDismissed: true,
browserDefaultZoomLevel: 1.5
browserDefaultZoomLevel: 1.5,
manualRepoOrder: [{ hostId: 'runtime:node-b', repoId: 'repo-b' }]
}
const runtime = {
getRuntimeId: () => 'test-runtime',
@ -310,7 +311,8 @@ describe('client UI RPC methods', () => {
contextualToursSeenIds: ['tasks'],
contextualToursAutoEligible: true,
usageEmptyStateDismissed: true,
browserDefaultZoomLevel: 1.5
browserDefaultZoomLevel: 1.5,
manualRepoOrder: [{ hostId: 'runtime:node-b', repoId: 'repo-b' }]
}
const response = await dispatcher.dispatch(makeRequest('ui.set', payload))

View File

@ -114,24 +114,34 @@ describe('migrateUiHostScopeSshTargetId', () => {
const makeUi = (overrides: Partial<PersistedUIState>): PersistedUIState =>
({ ...overrides }) as PersistedUIState
it('re-points scope, visible hosts, and host order, deduping collisions', () => {
it('re-points scope, visible hosts, host order, and manual repo order', () => {
const ui = makeUi({
workspaceHostScope: `ssh:${OLD_ID}`,
visibleWorkspaceHostIds: ['local', `ssh:${OLD_ID}`, `ssh:${NEW_ID}`],
workspaceHostOrder: [`ssh:${OLD_ID}`, 'local']
workspaceHostOrder: [`ssh:${OLD_ID}`, 'local'],
manualRepoOrder: [
{ hostId: `ssh:${OLD_ID}`, repoId: 'remote-repo' },
{ hostId: `ssh:${NEW_ID}`, repoId: 'remote-repo' },
{ hostId: 'local', repoId: 'local-repo' }
]
})
expect(migrateUiHostScopeSshTargetId(ui, OLD_ID, NEW_ID)).toBe(true)
expect(ui.workspaceHostScope).toBe(`ssh:${NEW_ID}`)
expect(ui.visibleWorkspaceHostIds).toEqual(['local', `ssh:${NEW_ID}`])
expect(ui.workspaceHostOrder).toEqual([`ssh:${NEW_ID}`, 'local'])
expect(ui.manualRepoOrder).toEqual([
{ hostId: `ssh:${NEW_ID}`, repoId: 'remote-repo' },
{ hostId: 'local', repoId: 'local-repo' }
])
})
it('returns false when the old host id appears nowhere', () => {
const ui = makeUi({
workspaceHostScope: 'all',
visibleWorkspaceHostIds: ['local'],
workspaceHostOrder: ['local']
workspaceHostOrder: ['local'],
manualRepoOrder: [{ hostId: 'local', repoId: 'local-repo' }]
})
expect(migrateUiHostScopeSshTargetId(ui, OLD_ID, NEW_ID)).toBe(false)

View File

@ -1,6 +1,7 @@
import type { PersistedUIState, WorkspaceSessionState } from '../../shared/types'
import { parseAppSshPtyId, toAppSshPtyId } from '../../shared/ssh-pty-id'
import { toSshExecutionHostId } from '../../shared/execution-host'
import { normalizeManualRepoOrder } from '../../shared/manual-repo-order'
/**
* Carrier sweep for SSH target re-adoption (see ssh-target-readoption.ts).
@ -112,5 +113,13 @@ export function migrateUiHostScopeSshTargetId(
]
changed = true
}
if (ui.manualRepoOrder?.some((entry) => entry.hostId === oldHostId)) {
ui.manualRepoOrder = normalizeManualRepoOrder(
ui.manualRepoOrder.map((entry) =>
entry.hostId === oldHostId ? { ...entry, hostId: newHostId } : entry
)
)
changed = true
}
return changed
}

View File

@ -964,6 +964,10 @@ export type PreloadApi = {
// other hosts (local or a re-added SSH target) intact.
removeForHost: (args: { repoId: string; hostId: string }) => Promise<void>
reorder: (args: { orderedIds: string[] }) => Promise<{ status: 'applied' | 'rejected' }>
reorderForHost: (args: {
orderedIds: string[]
hostId: string
}) => Promise<{ status: 'applied' | 'rejected' }>
update: (args: {
repoId: string
updates: Partial<

View File

@ -568,6 +568,8 @@ const api = {
reorder: (args) => ipcRenderer.invoke('repos:reorder', args),
reorderForHost: (args) => ipcRenderer.invoke('repos:reorderForHost', args),
update: (args) => ipcRenderer.invoke('repos:update', args),
pickFolder: () => ipcRenderer.invoke('repos:pickFolder'),

View File

@ -29,9 +29,11 @@ const reposRemove = vi.fn()
const reposRemoveForHost = vi.fn()
const reposUpdate = vi.fn()
const reposReorder = vi.fn()
const reposReorderForHost = vi.fn()
const ptyKill = vi.fn()
const runtimeEnvironmentCall = vi.fn()
const runtimeEnvironmentTransportCall = vi.fn()
const uiSet = vi.fn()
function deferred<T>() {
let resolve!: (value: T) => void
@ -63,9 +65,12 @@ beforeEach(() => {
reposRemoveForHost.mockReset()
reposUpdate.mockReset()
reposReorder.mockReset()
reposReorderForHost.mockReset()
ptyKill.mockReset()
runtimeEnvironmentCall.mockReset()
runtimeEnvironmentTransportCall.mockReset()
uiSet.mockReset()
uiSet.mockResolvedValue(undefined)
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
})
@ -75,10 +80,12 @@ beforeEach(() => {
remove: reposRemove,
removeForHost: reposRemoveForHost,
update: reposUpdate,
reorder: reposReorder
reorder: reposReorder,
reorderForHost: reposReorderForHost
},
pty: { kill: ptyKill },
runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
runtimeEnvironments: { call: runtimeEnvironmentTransportCall },
ui: { set: uiSet }
}
})
})
@ -457,7 +464,7 @@ describe('repo slice host identity routing', () => {
})
it('reorders duplicate repo ids once per owning host', async () => {
reposReorder.mockResolvedValue({ status: 'applied' })
reposReorderForHost.mockResolvedValue({ status: 'applied' })
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-duplicate-reorder',
ok: true,
@ -470,7 +477,17 @@ describe('repo slice host identity routing', () => {
await store.getState().reorderRepos(['same-repo', 'same-repo'])
expect(store.getState().repos).toEqual([localDuplicate, remoteDuplicate])
expect(reposReorder).toHaveBeenCalledWith({ orderedIds: ['same-repo'] })
expect(reposReorderForHost).toHaveBeenCalledWith({
hostId: 'local',
orderedIds: ['same-repo']
})
expect(reposReorder).not.toHaveBeenCalled()
expect(uiSet).toHaveBeenCalledWith({
manualRepoOrder: [
{ hostId: 'local', repoId: 'same-repo' },
{ hostId: 'runtime:env-1', repoId: 'same-repo' }
]
})
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'repo.reorder',
@ -478,4 +495,72 @@ describe('repo slice host identity routing', () => {
timeoutMs: 15_000
})
})
it('persists a complete cross-host overlay alongside host-local permutations', async () => {
reposReorderForHost.mockResolvedValue({ status: 'applied' })
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-cross-host-reorder',
ok: true,
result: { status: 'applied' },
_meta: { runtimeId: 'runtime-remote' }
})
const alpha = { ...localDuplicate, id: 'alpha' }
const bravo = { ...localDuplicate, id: 'bravo' }
const charlie = { ...remoteDuplicate, id: 'charlie' }
const delta = { ...remoteDuplicate, id: 'delta' }
const store = createTestStore()
store.setState({ repos: [alpha, bravo, charlie, delta] })
await store.getState().reorderRepos(['alpha', 'charlie', 'bravo', 'delta'])
expect(reposReorderForHost).toHaveBeenCalledWith({
hostId: 'local',
orderedIds: ['alpha', 'bravo']
})
expect(reposReorder).not.toHaveBeenCalled()
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'repo.reorder',
params: { orderedIds: ['charlie', 'delta'] },
timeoutMs: 15_000
})
expect(uiSet).toHaveBeenCalledWith({
manualRepoOrder: [
{ hostId: 'local', repoId: 'alpha' },
{ hostId: 'runtime:env-1', repoId: 'charlie' },
{ hostId: 'local', repoId: 'bravo' },
{ hostId: 'runtime:env-1', repoId: 'delta' }
]
})
})
it('persists local and direct SSH permutations through host-scoped IPC', async () => {
reposReorderForHost.mockResolvedValue({ status: 'applied' })
const alpha = { ...localDuplicate, id: 'alpha' }
const bravo = { ...localDuplicate, id: 'bravo' }
const charlie = {
...localDuplicate,
id: 'charlie',
path: '/ssh/charlie',
connectionId: 'target',
executionHostId: undefined
}
const delta = { ...charlie, id: 'delta', path: '/ssh/delta' }
const store = createTestStore()
store.setState({ repos: [alpha, charlie, bravo, delta] })
await store.getState().reorderRepos(['bravo', 'delta', 'alpha', 'charlie'])
expect(reposReorderForHost).toHaveBeenCalledTimes(2)
expect(reposReorderForHost).toHaveBeenCalledWith({
hostId: 'local',
orderedIds: ['bravo', 'alpha']
})
expect(reposReorderForHost).toHaveBeenCalledWith({
hostId: 'ssh:target',
orderedIds: ['delta', 'charlie']
})
expect(reposReorder).not.toHaveBeenCalled()
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/types'
import { createTestStore } from './store-test-helpers'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
type RuntimeEnvironmentCallRequest
} from '../../runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
const reposList = vi.fn()
const projectsList = vi.fn()
const listHostSetups = vi.fn()
const runtimeEnvironmentsList = vi.fn()
const runtimeEnvironmentCall = vi.fn()
const runtimeEnvironmentTransportCall = vi.fn()
const reposByEnvironment: Record<string, Repo[]> = {
'node-a': [
{ id: 'alpha', path: '/alpha', displayName: 'alpha', badgeColor: '#000', addedAt: 1 },
{ id: 'bravo', path: '/bravo', displayName: 'bravo', badgeColor: '#000', addedAt: 2 }
],
'node-b': [
{ id: 'charlie', path: '/charlie', displayName: 'charlie', badgeColor: '#000', addedAt: 1 },
{ id: 'delta', path: '/delta', displayName: 'delta', badgeColor: '#000', addedAt: 2 }
]
}
type RepoListResolver = (value: unknown) => void
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
reposList.mockReset().mockResolvedValue([])
projectsList.mockReset().mockResolvedValue([])
listHostSetups.mockReset().mockResolvedValue([])
runtimeEnvironmentsList.mockReset().mockResolvedValue([
{ id: 'node-a', name: 'A' },
{ id: 'node-b', name: 'B' }
])
runtimeEnvironmentCall.mockReset()
runtimeEnvironmentTransportCall.mockReset()
runtimeEnvironmentTransportCall.mockImplementation(
(args: RuntimeEnvironmentCallRequest) =>
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
)
vi.stubGlobal('window', {
api: {
repos: { list: reposList },
projects: { list: projectsList, listHostSetups },
runtimeEnvironments: {
list: runtimeEnvironmentsList,
call: runtimeEnvironmentTransportCall
}
}
})
})
async function loadWithCompletionOrder(completionOrder: string[]): Promise<string[]> {
clearRuntimeCompatibilityCacheForTests()
const repoListResolvers = new Map<string, RepoListResolver>()
runtimeEnvironmentCall.mockImplementation(
(args: RuntimeEnvironmentCallRequest & { selector?: string }) => {
if (args.method === 'repo.list' && args.selector) {
return new Promise((resolve) => repoListResolvers.set(args.selector!, resolve))
}
const result = args.method === 'project.list' ? { projects: [] } : { setups: [] }
return { id: `rpc-${args.method}`, ok: true, result, _meta: { runtimeId: 'runtime' } }
}
)
const store = createTestStore()
store.setState({
manualRepoOrder: [
{ hostId: 'runtime:node-a', repoId: 'alpha' },
{ hostId: 'runtime:node-b', repoId: 'charlie' },
{ hostId: 'runtime:node-a', repoId: 'bravo' },
{ hostId: 'runtime:node-b', repoId: 'delta' }
]
})
const load = store.getState().fetchReposForAllHosts()
await vi.waitFor(() => expect(repoListResolvers.size).toBe(2))
for (const environmentId of completionOrder) {
repoListResolvers.get(environmentId)?.({
id: `rpc-repo-${environmentId}`,
ok: true,
result: { repos: reposByEnvironment[environmentId] },
_meta: { runtimeId: environmentId }
})
await Promise.resolve()
}
await load
expect(store.getState().manualRepoOrder).toHaveLength(4)
return store.getState().repos.map((repo) => `${repo.executionHostId}:${repo.id}`)
}
describe('manual repo order hydration', () => {
it('restores the same cross-host order for either catalog completion order', async () => {
const expected = [
'runtime:node-a:alpha',
'runtime:node-b:charlie',
'runtime:node-a:bravo',
'runtime:node-b:delta'
]
await expect(loadWithCompletionOrder(['node-b', 'node-a'])).resolves.toEqual(expected)
await expect(loadWithCompletionOrder(['node-a', 'node-b'])).resolves.toEqual(expected)
})
})

View File

@ -44,6 +44,7 @@ export const reposCloneRemote: Mock = vi.fn()
export const reposRemove: Mock = vi.fn()
export const reposUpdate: Mock = vi.fn()
export const reposReorder: Mock = vi.fn()
export const reposReorderForHost: Mock = vi.fn()
export const projectsCreateHostSetup: Mock = vi.fn()
export const projectsSetupExistingFolder: Mock = vi.fn()
export const projectsUpdateHostSetup: Mock = vi.fn()
@ -54,6 +55,7 @@ export const ptyKill: Mock = vi.fn()
export const runtimeEnvironmentCall: Mock = vi.fn()
export const runtimeEnvironmentTransportCall: Mock = vi.fn()
export const orcaProfileFindProjectProfiles: Mock = vi.fn()
export const uiSet: Mock = vi.fn()
// Registers the per-test reset + window stub. Call once inside the suite's module scope.
export function installReposRuntimeRoutingHarness(): void {
@ -71,6 +73,7 @@ export function installReposRuntimeRoutingHarness(): void {
reposRemove.mockReset()
reposUpdate.mockReset()
reposReorder.mockReset()
reposReorderForHost.mockReset()
projectsCreateHostSetup.mockReset()
projectsSetupExistingFolder.mockReset()
projectsUpdateHostSetup.mockReset()
@ -81,6 +84,8 @@ export function installReposRuntimeRoutingHarness(): void {
orcaProfileFindProjectProfiles.mockReset()
runtimeEnvironmentCall.mockReset()
runtimeEnvironmentTransportCall.mockReset()
uiSet.mockReset()
uiSet.mockResolvedValue(undefined)
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
})
@ -94,7 +99,8 @@ export function installReposRuntimeRoutingHarness(): void {
pickFolder: reposPickFolder,
remove: reposRemove,
update: reposUpdate,
reorder: reposReorder
reorder: reposReorder,
reorderForHost: reposReorderForHost
},
projects: {
update: projectsUpdate,
@ -110,7 +116,8 @@ export function installReposRuntimeRoutingHarness(): void {
findProjectProfiles: orcaProfileFindProjectProfiles
},
pty: { kill: ptyKill },
runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
runtimeEnvironments: { call: runtimeEnvironmentTransportCall },
ui: { set: uiSet }
}
})
})

View File

@ -44,6 +44,7 @@ import {
import { isGitRepoKind } from '../../../../shared/repo-kind'
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { applyManualRepoOrder, getManualRepoOrder } from '../../../../shared/manual-repo-order'
import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups'
import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
@ -1567,7 +1568,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
// Drop rows on unknown SSH targets that a live-host sibling supersedes.
const result = mergeFetchedRepoCatalog(catalog, s.repos)
const reconciliation = reconcileSupersededSshRepos(result.repos, s)
const prunedRepos = reconciliation.repos
const prunedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder)
const validRepoIds = new Set(prunedRepos.map((repo) => repo.id))
const projectCompatibility = projectCompatibilityForReconciledRepos(
prunedRepos,
@ -1617,7 +1618,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
set((s) => {
const result = mergeFetchedRepoCatalog(catalog, s.repos)
const reconciliation = reconcileSupersededSshRepos(result.repos, s)
const finalizedRepos = reconciliation.repos
const finalizedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder)
const validRepoIds = new Set(finalizedRepos.map((repo) => repo.id))
const projectCompatibility = projectCompatibilityForReconciledRepos(
finalizedRepos,
@ -1683,7 +1684,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
set((s) => {
const result = mergeFetchedRepoCatalog(catalog, s.repos)
const reconciliation = reconcileSupersededSshRepos(result.repos, s)
const finalizedRepos = reconciliation.repos
const finalizedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder)
const projectCompatibility = projectCompatibilityForReconciledRepos(
finalizedRepos,
catalog.projectHostSetupCompatibility
@ -3122,8 +3123,10 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
// Caller passed a non-permutation — refuse to apply locally.
return
}
const manualRepoOrder = getManualRepoOrder(next)
set({
repos: next,
manualRepoOrder,
folderWorkspacePathStatuses: {}
})
try {
@ -3131,23 +3134,31 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
// so split the cross-host order into per-host permutations and dispatch one
// reorder per owner host.
const groups = splitRepoReorderByHost(orderedIds, next, get().settings)
const results = await Promise.all(
groups.map(async (group) => {
const parsed = parseExecutionHostId(group.hostId)
const target =
parsed?.kind === 'runtime'
? ({ kind: 'environment', environmentId: parsed.environmentId } as const)
: ({ kind: 'local' } as const)
return target.kind === 'local'
? window.api.repos.reorder({ orderedIds: group.orderedIds })
: callRuntimeRpc<{ status: 'applied' | 'rejected' }>(
target,
'repo.reorder',
{ orderedIds: group.orderedIds },
{ timeoutMs: 15_000 }
)
})
)
const [results] = await Promise.all([
Promise.all(
groups.map(async (group) => {
const parsed = parseExecutionHostId(group.hostId)
const target =
parsed?.kind === 'runtime'
? ({ kind: 'environment', environmentId: parsed.environmentId } as const)
: ({ kind: 'local' } as const)
return target.kind === 'local'
? window.api.repos.reorderForHost({
hostId: group.hostId,
orderedIds: group.orderedIds
})
: callRuntimeRpc<{ status: 'applied' | 'rejected' }>(
target,
'repo.reorder',
{ orderedIds: group.orderedIds },
{ timeoutMs: 15_000 }
)
})
),
// Why: servers can only persist their local permutations. The desktop
// profile owns the cross-host relationships needed after a cold load.
window.api.ui.set({ manualRepoOrder })
])
if (results.some((result) => result.status === 'rejected')) {
await get().fetchReposForAllHosts()
}

View File

@ -7,6 +7,7 @@ import type {
JiraIssue,
LinearIssue,
PersistedUIState,
Repo,
TerminalTab,
Worktree
} from '../../../../shared/types'
@ -725,6 +726,8 @@ describe('createUISlice hydratePersistedUI', () => {
expect(createUIStore().getState().visibleWorkspaceHostIds).toBeNull()
expect(getDefaultUIState().workspaceHostOrder).toEqual([])
expect(createUIStore().getState().workspaceHostOrder).toEqual([])
expect(getDefaultUIState().manualRepoOrder).toEqual([])
expect(createUIStore().getState().manualRepoOrder).toEqual([])
})
it('defaults the persisted active view to terminal', () => {
@ -971,6 +974,41 @@ describe('createUISlice hydratePersistedUI', () => {
expect(store.getState().workspaceHostOrder).toEqual(['ssh:win%20vm', 'local'])
})
it('hydrates and immediately applies the manual cross-host repo order', () => {
const store = createUIStore()
const local: Repo = {
id: 'same',
path: '/local',
displayName: 'Local',
badgeColor: '#000',
addedAt: 1,
executionHostId: 'local'
}
const remote: Repo = {
...local,
path: '/remote',
displayName: 'Remote',
executionHostId: 'runtime:node-b'
}
store.setState({ repos: [local, remote] })
store.getState().hydratePersistedUI(
makePersistedUI({
manualRepoOrder: [
{ hostId: 'runtime:node-b', repoId: 'same' },
{ hostId: 'invalid' as never, repoId: 'ignored' },
{ hostId: 'local', repoId: 'same' }
]
})
)
expect(store.getState().manualRepoOrder).toEqual([
{ hostId: 'runtime:node-b', repoId: 'same' },
{ hostId: 'local', repoId: 'same' }
])
expect(store.getState().repos).toEqual([remote, local])
})
it('falls back to all hosts for invalid persisted workspace host scopes', () => {
const store = createUIStore()

View File

@ -12,6 +12,7 @@ import type {
GitHubWorkItem,
JiraIssue,
LinearIssue,
ManualRepoOrderEntry,
PersistedTrustedOrcaHooks,
PersistedUIState,
StatusBarItem,
@ -30,6 +31,10 @@ import type {
VisibleWorkspaceHostIds,
TopLevelView
} from '../../../../shared/types'
import {
applyManualRepoOrder,
normalizeManualRepoOrder
} from '../../../../shared/manual-repo-order'
import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display'
import {
DEFAULT_USAGE_PERCENTAGE_DISPLAY,
@ -889,6 +894,7 @@ export type UISlice = {
setVisibleWorkspaceHostIds: (ids: VisibleWorkspaceHostIds) => void
workspaceHostOrder: WorkspaceHostOrder
setWorkspaceHostOrder: (ids: WorkspaceHostOrder) => void
manualRepoOrder: ManualRepoOrderEntry[]
hideDefaultBranchWorkspace: boolean
setHideDefaultBranchWorkspace: (v: boolean) => void
hideAutomationGeneratedWorkspaces: boolean
@ -2060,6 +2066,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
set({ workspaceHostOrder })
window.api.ui.set({ workspaceHostOrder }).catch(console.error)
},
manualRepoOrder: [],
hideDefaultBranchWorkspace: false,
setHideDefaultBranchWorkspace: (v) => set({ hideDefaultBranchWorkspace: v }),
@ -2372,6 +2379,8 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
hydratePersistedUI: (ui, source = 'sync') =>
set((s) => {
const manualRepoOrder = normalizeManualRepoOrder(ui.manualRepoOrder)
const orderedRepos = applyManualRepoOrder(s.repos, manualRepoOrder)
const validRepoIds = new Set(s.repos.map((repo) => repo.id))
const persistedFilterRepoIds = sanitizePersistedRepoIds(ui.filterRepoIds)
// Why: persisted UI from pre-rename builds used sidekick* keys. Read
@ -2474,6 +2483,10 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
workspaceHostScope: normalizeExecutionHostScope(ui.workspaceHostScope),
visibleWorkspaceHostIds: normalizeHydratedVisibleWorkspaceHostIds(ui),
workspaceHostOrder: normalizeExecutionHostOrder(ui.workspaceHostOrder),
manualRepoOrder,
// Why: UI state can arrive after a catalog or from another client; apply
// the desktop-owned overlay immediately instead of waiting for a refetch.
repos: orderedRepos,
hideDefaultBranchWorkspace: ui.hideDefaultBranchWorkspace ?? false,
hideAutomationGeneratedWorkspaces: ui.hideAutomationGeneratedWorkspaces === true,
showDotfilesByWorktree: sanitizeShowDotfilesByWorktree(ui.showDotfilesByWorktree),

View File

@ -1780,6 +1780,14 @@ describe('web repos preload API', () => {
vi.doUnmock('./web-runtime-client')
})
it('rejects desktop host-scoped reorders in paired web clients', async () => {
const { api } = await installApi('Linux')
await expect(
api.repos.reorderForHost({ hostId: 'ssh:target', orderedIds: ['repo-1'] })
).rejects.toThrow('Host-scoped project reordering is unavailable in paired web clients.')
})
it.each([
['/home/alice', '/home/alice/orca/projects'],
['/', '/orca/projects'],

View File

@ -1264,6 +1264,11 @@ function createReposApi(): NonNullable<Partial<PreloadApi>['repos']> {
throw new Error('Forgetting a host is unavailable in paired web clients.')
},
reorder: async ({ orderedIds }) => callRuntimeResult('repo.reorder', { orderedIds }),
// Why: this path persists desktop-owned local or SSH rows. Paired web
// clients own only their single runtime, which uses repo.reorder directly.
reorderForHost: async () => {
throw new Error('Host-scoped project reordering is unavailable in paired web clients.')
},
update: async ({ repoId, updates }) =>
(await callRuntimeResult<{ repo: Repo }>('repo.update', { repo: repoId, updates })).repo,
pickFolder: () => Promise.resolve(null),

View File

@ -481,6 +481,7 @@ export function getDefaultUIState(): PersistedUIState {
workspaceHostScope: 'all',
visibleWorkspaceHostIds: null,
workspaceHostOrder: [],
manualRepoOrder: [],
showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES,
hideDefaultBranchWorkspace: false,
hideAutomationGeneratedWorkspaces: false,

View File

@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import type { ManualRepoOrderEntry, Repo } from './types'
import {
applyManualRepoOrder,
getManualRepoOrder,
normalizeManualRepoOrder
} from './manual-repo-order'
function repo(id: string, hostId: Repo['executionHostId']): Repo {
return {
id,
path: `/${hostId}/${id}`,
displayName: id,
badgeColor: '#000',
addedAt: 1,
executionHostId: hostId
}
}
const localAlpha = repo('alpha', 'local')
const localBravo = repo('bravo', 'local')
const remoteCharlie = repo('charlie', 'runtime:node-b')
const remoteDelta = repo('delta', 'runtime:node-b')
describe('manual repo order', () => {
it('preserves source order when no overlay exists', () => {
expect(applyManualRepoOrder([localBravo, localAlpha], [])).toEqual([localBravo, localAlpha])
})
it('restores a host-qualified cross-host interleaving', () => {
const order = getManualRepoOrder([localAlpha, remoteCharlie, localBravo, remoteDelta])
expect(
applyManualRepoOrder([remoteCharlie, remoteDelta, localAlpha, localBravo], order)
).toEqual([localAlpha, remoteCharlie, localBravo, remoteDelta])
})
it('places a late host into its saved positions', () => {
const order = getManualRepoOrder([localAlpha, remoteCharlie, localBravo, remoteDelta])
const localOnly = applyManualRepoOrder([localBravo, localAlpha], order)
expect(applyManualRepoOrder([...localOnly, remoteDelta, remoteCharlie], order)).toEqual([
localAlpha,
remoteCharlie,
localBravo,
remoteDelta
])
})
it('distinguishes duplicate bare repo ids on different hosts', () => {
const local = repo('same-id', 'local')
const remote = repo('same-id', 'runtime:node-b')
const order = getManualRepoOrder([remote, local])
expect(applyManualRepoOrder([local, remote], order)).toEqual([remote, local])
})
it('appends unranked repos in their source order', () => {
const newOne = repo('new-one', 'local')
const newTwo = repo('new-two', 'runtime:node-b')
const order = getManualRepoOrder([remoteCharlie, localAlpha])
expect(applyManualRepoOrder([newTwo, localAlpha, newOne, remoteCharlie], order)).toEqual([
remoteCharlie,
localAlpha,
newTwo,
newOne
])
})
it('normalizes malformed, invalid-host, and duplicate entries', () => {
const value = [
null,
{ hostId: 'bogus', repoId: 'bad-host' },
{ hostId: 'local', repoId: '' },
{ hostId: 'runtime:node-b', repoId: 'same-id' },
{ hostId: 'runtime:node-b', repoId: 'same-id' },
{ hostId: 'local', repoId: 'same-id' }
] as unknown as ManualRepoOrderEntry[]
expect(normalizeManualRepoOrder(value)).toEqual([
{ hostId: 'runtime:node-b', repoId: 'same-id' },
{ hostId: 'local', repoId: 'same-id' }
])
})
})

View File

@ -0,0 +1,69 @@
import { getRepoExecutionHostId, normalizeExecutionHostId } from './execution-host'
import type { ManualRepoOrderEntry, Repo } from './types'
function getEntryKey(entry: ManualRepoOrderEntry): string {
return `${entry.hostId}\0${entry.repoId}`
}
export function normalizeManualRepoOrder(value: unknown): ManualRepoOrderEntry[] {
if (!Array.isArray(value)) {
return []
}
const entries: ManualRepoOrderEntry[] = []
const seen = new Set<string>()
for (const candidate of value) {
if (!candidate || typeof candidate !== 'object') {
continue
}
const raw = candidate as { hostId?: unknown; repoId?: unknown }
const hostId = typeof raw.hostId === 'string' ? normalizeExecutionHostId(raw.hostId) : null
const repoId = typeof raw.repoId === 'string' ? raw.repoId : ''
if (!hostId || !repoId.trim()) {
continue
}
const entry = { hostId, repoId }
const key = getEntryKey(entry)
if (seen.has(key)) {
continue
}
seen.add(key)
entries.push(entry)
}
return entries
}
export function getManualRepoOrder(repos: readonly Repo[]): ManualRepoOrderEntry[] {
return normalizeManualRepoOrder(
repos.map((repo) => ({ hostId: getRepoExecutionHostId(repo), repoId: repo.id }))
)
}
export function applyManualRepoOrder(
repos: readonly Repo[],
order: readonly ManualRepoOrderEntry[] | null | undefined
): Repo[] {
const normalized = normalizeManualRepoOrder(order)
if (normalized.length === 0) {
return [...repos]
}
const rankByKey = new Map(normalized.map((entry, index) => [getEntryKey(entry), index]))
return repos
.map((repo, index) => ({
repo,
index,
rank: rankByKey.get(getEntryKey({ hostId: getRepoExecutionHostId(repo), repoId: repo.id }))
}))
.sort((a, b) => {
if (a.rank === undefined && b.rank === undefined) {
return a.index - b.index
}
if (a.rank === undefined) {
return 1
}
if (b.rank === undefined) {
return -1
}
return a.rank - b.rank || a.index - b.index
})
.map(({ repo }) => repo)
}

View File

@ -3244,6 +3244,10 @@ export type ProjectOrderBy = 'manual' | 'recent'
export type WorkspaceHostScope = 'all' | 'local' | `ssh:${string}` | `runtime:${string}`
export type VisibleWorkspaceHostIds = Exclude<WorkspaceHostScope, 'all'>[] | null
export type WorkspaceHostOrder = Exclude<WorkspaceHostScope, 'all'>[]
export type ManualRepoOrderEntry = {
hostId: WorkspaceHostOrder[number]
repoId: string
}
/** The active top-level section shown in the main content area. */
export type TopLevelView =
@ -3291,6 +3295,9 @@ export type PersistedUIState = {
/** User-defined sidebar order for host sections. Missing/new hosts append in
* the discovered host order. */
workspaceHostOrder?: WorkspaceHostOrder
/** Desktop-owned all-host repo order. Host-qualified identities preserve a
* manual cross-host interleaving while each host owns its local permutation. */
manualRepoOrder?: ManualRepoOrderEntry[]
/** Deprecated legacy positive-form setting. Ignored on hydration. */
showSleepingWorkspaces?: boolean
/** Deprecated legacy name used by a short-lived build. Ignored on hydration. */