From 79ff2475c1e99f6c63cc2fb41199748bd17ae5ad Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 10 Jun 2026 20:11:59 -0700 Subject: [PATCH] Gate unavailable project host setup options Co-authored-by: Orca --- .../ProjectHostSetupCombobox.test.tsx | 51 +++++++++++++++- .../ProjectHostSetupCombobox.tsx | 4 ++ .../lib/project-host-setup-options.test.ts | 60 +++++++++++++++++-- .../src/lib/project-host-setup-options.ts | 37 +++++++++++- 4 files changed, 141 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx index 1b9a9ba66af..cf8670a6943 100644 --- a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx +++ b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx @@ -21,14 +21,21 @@ vi.mock('@/components/ui/command', () => ({ CommandList: ({ children }: { children: React.ReactNode }) =>
{children}
, CommandItem: ({ children, + disabled, onSelect, value }: { children: React.ReactNode + disabled?: boolean onSelect?: (value: string) => void value: string }) => ( - ) @@ -54,7 +61,18 @@ const needsSetupOption: NeedsSetupProjectHostOption = { projectId: 'project-1', hostId: 'ssh:builder', label: 'Builder', - detail: 'Project not set up on this host' + detail: 'Project not set up on this host', + isAvailable: true +} + +const unavailableOption: NeedsSetupProjectHostOption = { + id: 'needs-setup:runtime:old', + kind: 'needs-setup', + projectId: 'project-1', + hostId: 'runtime:old', + label: 'Old server', + detail: 'Update Orca on this host to set up projects', + isAvailable: false } beforeEach(() => { @@ -121,4 +139,33 @@ describe('ProjectHostSetupCombobox', () => { expect(onValueChange).not.toHaveBeenCalled() expect(onNeedsSetupHostSelect).toHaveBeenCalledWith(needsSetupOption) }) + + it('keeps unavailable setup rows visible but not selectable', () => { + const onValueChange = vi.fn() + const onNeedsSetupHostSelect = vi.fn() + + act(() => { + root.render( + + ) + }) + + const unavailableButton = container.querySelector( + '[data-command-value="needs-setup:runtime:old"]' + ) + expect(unavailableButton?.textContent).toContain('Update Orca on this host') + expect(unavailableButton?.disabled).toBe(true) + + act(() => { + unavailableButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(onValueChange).not.toHaveBeenCalled() + expect(onNeedsSetupHostSelect).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx index fcfac39a209..bac4ab195a3 100644 --- a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx +++ b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx @@ -34,6 +34,9 @@ export default function ProjectHostSetupCombobox({ return } if (option.kind === 'needs-setup') { + if (!option.isAvailable) { + return + } onNeedsSetupHostSelect?.(option) setOpen(false) return @@ -90,6 +93,7 @@ export default function ProjectHostSetupCombobox({ key={option.id} value={option.id} onSelect={() => handleSelect(option.id)} + disabled={option.kind === 'needs-setup' && !option.isAvailable} className="items-center gap-2 px-3 py-2" > { id: 'needs-setup:ssh:builder', kind: 'needs-setup', label: 'Builder', - detail: 'Project not set up on this host' + detail: 'Project not set up on this host', + isAvailable: true }) ]) }) @@ -105,7 +106,10 @@ describe('buildProjectHostSetupOptions', () => { const options = buildProjectHostSetupOptions({ projectId: 'project-1', eligibleRepos: [repo('local-repo')], - hosts: [host('local'), host('runtime:gpu', { label: 'GPU VM' })], + hosts: [ + host('local'), + host('runtime:gpu', { label: 'GPU VM', capabilities: ['project-host-setup.v1'] }) + ], projectHostSetups: [ setup('local', 'project-1', 'local', 'local-repo'), setup('gpu-pending', 'project-1', 'runtime:gpu', '', { @@ -122,7 +126,8 @@ describe('buildProjectHostSetupOptions', () => { id: 'needs-setup:runtime:gpu', kind: 'needs-setup', label: 'GPU VM', - detail: 'Project setup is in progress' + detail: 'Project setup is in progress', + isAvailable: true }) ]) }) @@ -137,7 +142,7 @@ describe('buildProjectHostSetupOptions', () => { expect( buildProjectHostSetupOptions({ ...base, - hosts: [host('runtime:gpu', { label: 'GPU VM' })], + hosts: [host('runtime:gpu', { label: 'GPU VM', capabilities: ['project-host-setup.v1'] })], projectHostSetups: [ ...base.projectHostSetups, setup('gpu-pending', 'project-1', 'runtime:gpu', '', { @@ -152,7 +157,7 @@ describe('buildProjectHostSetupOptions', () => { expect( buildProjectHostSetupOptions({ ...base, - hosts: [host('runtime:gpu', { label: 'GPU VM' })], + hosts: [host('runtime:gpu', { label: 'GPU VM', capabilities: ['project-host-setup.v1'] })], projectHostSetups: [ ...base.projectHostSetups, setup('gpu-pending', 'project-1', 'runtime:gpu', '', { @@ -167,7 +172,7 @@ describe('buildProjectHostSetupOptions', () => { expect( buildProjectHostSetupOptions({ ...base, - hosts: [host('runtime:gpu', { label: 'GPU VM' })], + hosts: [host('runtime:gpu', { label: 'GPU VM', capabilities: ['project-host-setup.v1'] })], projectHostSetups: [ ...base.projectHostSetups, setup('gpu-pending', 'project-1', 'runtime:gpu', '', { @@ -179,4 +184,47 @@ describe('buildProjectHostSetupOptions', () => { }).at(-1) ).toMatchObject({ detail: 'Project is unsupported on this host' }) }) + + it('marks incompatible runtime hosts as visible but unavailable', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('local-repo')], + hosts: [ + host('local'), + host('runtime:gpu', { + label: 'GPU VM', + health: 'blocked', + capabilities: ['project-host-setup.v1'] + }) + ], + projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')] + }) + + expect(options).toEqual([ + expect.objectContaining({ id: 'local', kind: 'ready', label: 'Local Mac' }), + expect.objectContaining({ + id: 'needs-setup:runtime:gpu', + kind: 'needs-setup', + label: 'GPU VM', + detail: 'Orca server version is incompatible', + isAvailable: false + }) + ]) + }) + + it('marks runtime hosts without project setup capability as unavailable', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('local-repo')], + hosts: [host('local'), host('runtime:gpu', { label: 'GPU VM', capabilities: [] })], + projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')] + }) + + expect(options.at(-1)).toMatchObject({ + id: 'needs-setup:runtime:gpu', + kind: 'needs-setup', + detail: 'Update Orca on this host to set up projects', + isAvailable: false + }) + }) }) diff --git a/src/renderer/src/lib/project-host-setup-options.ts b/src/renderer/src/lib/project-host-setup-options.ts index 7e21cff9cf9..6fca5840bf9 100644 --- a/src/renderer/src/lib/project-host-setup-options.ts +++ b/src/renderer/src/lib/project-host-setup-options.ts @@ -4,6 +4,7 @@ import { type ExecutionHostId } from '../../../shared/execution-host' import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' +import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import type { ProjectHostSetup, Repo } from '../../../shared/types' export type ProjectHostSetupOption = @@ -24,6 +25,7 @@ export type ProjectHostSetupOption = hostId: ExecutionHostId label: string detail: string + isAvailable: boolean } export type ReadyProjectHostSetupOption = Extract @@ -124,19 +126,48 @@ function buildNeedsSetupOptions({ .filter((host) => !readySetupByHost.has(host.id)) .map((host) => { const pendingSetup = pendingSetupByHost.get(host.id) + const availability = getHostSetupAvailability(host) return { id: `needs-setup:${host.id}`, kind: 'needs-setup' as const, projectId, hostId: host.id, label: host.label || getExecutionHostLabel(host.id), - detail: pendingSetup - ? getPendingSetupDetail(pendingSetup) - : 'Project not set up on this host' + detail: availability.isAvailable + ? pendingSetup + ? getPendingSetupDetail(pendingSetup) + : 'Project not set up on this host' + : availability.detail, + isAvailable: availability.isAvailable } }) } +function getHostSetupAvailability(host: ExecutionHostRegistryEntry): { + isAvailable: boolean + detail: string +} { + if (host.health === 'blocked') { + return { + isAvailable: false, + detail: 'Orca server version is incompatible' + } + } + if ( + host.kind === 'runtime' && + !host.capabilities?.includes(PROJECT_HOST_SETUP_RUNTIME_CAPABILITY) + ) { + return { + isAvailable: false, + detail: 'Update Orca on this host to set up projects' + } + } + return { + isAvailable: true, + detail: '' + } +} + function getPendingSetupDetail(setup: ProjectHostSetup): string { switch (setup.setupState) { case 'not-set-up':