From f70f9208466ce240cf92b93e86debd48fcc1de1c Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 8 Jul 2026 16:23:55 -0400 Subject: [PATCH] Show setup-needed hosts in workspace run picker Co-authored-by: Orca --- .../NewWorkspaceComposerCard.test.tsx | 77 +++++++++++++++ .../components/NewWorkspaceComposerCard.tsx | 97 +++++++++++++------ .../lib/project-host-setup-options.test.ts | 23 +++++ 3 files changed, 170 insertions(+), 27 deletions(-) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index 2883fdc5cad..bb06270f46d 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -6,6 +6,7 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import NewWorkspaceComposerCard from './NewWorkspaceComposerCard' import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' +import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options' vi.mock('@/store', () => ({ useAppStore: (selector: (state: unknown) => unknown) => @@ -105,6 +106,27 @@ const sourceRepos = [ } ] +const localReadyHostOption: ProjectHostSetupOption = { + kind: 'ready', + id: 'setup-local', + projectId: 'project-group:platform', + hostId: 'local', + repoId: 'repo-a', + label: 'Local Mac', + detail: 'Orca', + path: '/Users/alice/orca' +} + +const devboxNeedsSetupHostOption: ProjectHostSetupOption = { + kind: 'needs-setup', + id: 'needs-setup:ssh:devbox', + projectId: 'project-group:platform', + hostId: 'ssh:devbox', + label: 'Devbox', + detail: 'Project not set up on this host', + isAvailable: true +} + function renderCard( overrides: Partial> = {} ) { @@ -195,6 +217,18 @@ function changeInputValue(input: HTMLInputElement, value: string): void { }) } +function openRunTargetPicker(container: HTMLElement): void { + const runTargetButton = container.querySelector('button[role="combobox"]') + expect(runTargetButton).toBeTruthy() + act(() => runTargetButton?.click()) +} + +function findRunTargetItem(label: string): HTMLElement | undefined { + return [...document.body.querySelectorAll('[cmdk-item]')].find((item) => + item.textContent?.includes(label) + ) +} + let current: { container: HTMLDivElement; root: Root } | null = null describe('NewWorkspaceComposerCard folder task source mode', () => { @@ -420,6 +454,49 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { ).toBe('false') }) + it('shows setup-needed hosts in the run target picker when one setup is ready', () => { + current = renderCard({ + projectHostSetupOptions: [localReadyHostOption, devboxNeedsSetupHostOption], + selectedProjectHostSetupId: 'setup-local' + }) + + expect(current.container.textContent).toContain('Run on') + openRunTargetPicker(current.container) + + const devboxItem = findRunTargetItem('Devbox') + expect(devboxItem?.textContent).toContain('Project not set up on this host') + expect( + devboxItem?.getAttribute('aria-disabled') === 'true' || + devboxItem?.hasAttribute('data-disabled') + ).toBe(true) + }) + + it('hides the run target picker when only one setup is ready', () => { + current = renderCard({ + projectHostSetupOptions: [localReadyHostOption], + selectedProjectHostSetupId: 'setup-local' + }) + + expect(current.container.textContent).not.toContain('Run on') + expect(current.container.querySelector('button[role="combobox"]')).toBeNull() + }) + + it('does not select setup-needed run target rows', () => { + const hostChanges: string[] = [] + current = renderCard({ + projectHostSetupOptions: [localReadyHostOption, devboxNeedsSetupHostOption], + selectedProjectHostSetupId: 'setup-local', + onProjectHostSetupChange: (setupId) => hostChanges.push(setupId) + }) + + openRunTargetPicker(current.container) + const devboxItem = findRunTargetItem('Devbox') + expect(devboxItem).toBeTruthy() + act(() => devboxItem?.click()) + + expect(hostChanges).toEqual([]) + }) + it('shows VM recipes inside the run target picker', () => { const hostChanges: string[] = [] const recipeChanges: (string | null)[] = [] diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index c7d96c14fef..6dbf4812d79 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -222,7 +222,7 @@ function getRecipeDestroyLabel(recipe: EphemeralVmRecipeOption): string { } type WorkspaceRunTargetComboboxProps = { - hostOptions: readonly ReadyProjectHostSetupOption[] + hostOptions: readonly ProjectHostSetupOption[] hostValue: string | null onHostChange?: (setupId: string) => void recipes: EphemeralVmRecipeOption[] @@ -240,8 +240,15 @@ function WorkspaceRunTargetCombobox({ }: WorkspaceRunTargetComboboxProps): React.JSX.Element { const [open, setOpen] = React.useState(false) const [vmRecipesOpen, setVmRecipesOpen] = React.useState(false) + const readyHostOptions = React.useMemo( + () => + hostOptions.filter( + (option): option is ReadyProjectHostSetupOption => option.kind === 'ready' + ), + [hostOptions] + ) const selectedHost = - hostOptions.find((option) => option.id === hostValue) ?? hostOptions[0] ?? null + readyHostOptions.find((option) => option.id === hostValue) ?? readyHostOptions[0] ?? null const selectedRecipe = recipes.find((recipe) => recipe.id === recipeValue) ?? null const selectedValue = selectedRecipe ? `recipe:${selectedRecipe.id}` @@ -255,14 +262,14 @@ function WorkspaceRunTargetCombobox({ const handleHostSelect = React.useCallback( (setupId: string): void => { - if (!hostOptions.some((candidate) => candidate.id === setupId)) { + if (!readyHostOptions.some((candidate) => candidate.id === setupId)) { return } onHostChange?.(setupId) onRecipeChange?.(null) setOpen(false) }, - [hostOptions, onHostChange, onRecipeChange] + [onHostChange, onRecipeChange, readyHostOptions] ) const handleRecipeSelect = React.useCallback( @@ -322,28 +329,54 @@ function WorkspaceRunTargetCombobox({ 'No run targets are ready for this project.' )} - {hostOptions.map((option) => ( - handleHostSelect(option.id)} - className="items-center gap-2 px-3 py-2" - > - - -
-
{option.label}
-
- {option.path} + {hostOptions.map((option) => + option.kind === 'ready' ? ( + handleHostSelect(option.id)} + className="items-center gap-2 px-3 py-2" + > + + +
+
{option.label}
+
+ {option.path} +
-
- - ))} + + ) : ( + // Why: setup-on-host is a follow-up; this row only explains why the host cannot + // run the workspace yet. + + + {option.isAvailable ? ( + + ) : ( + + )} +
+
{option.label}
+
+ {option.detail} +
+
+
+ ) + )} {recipes.length > 0 ? ( @@ -768,6 +801,16 @@ export default function NewWorkspaceComposerCard({ () => projectHostSetupOptions.filter((option) => option.kind === 'ready'), [projectHostSetupOptions] ) + const needsSetupProjectHostSetupOptions = React.useMemo( + () => projectHostSetupOptions.filter((option) => option.kind === 'needs-setup'), + [projectHostSetupOptions] + ) + // Why: one ready host plus another host that needs setup should be discoverable, while + // the single-ready-only case remains compact. + const shouldShowRunTargetPicker = + readyProjectHostSetupOptions.length > 1 || + ephemeralVmRecipes.length > 0 || + (readyProjectHostSetupOptions.length > 0 && needsSetupProjectHostSetupOptions.length > 0) const handleProjectHostSetupChange = React.useCallback( (setupId: string): void => { onProjectHostSetupChange?.(setupId) @@ -861,13 +904,13 @@ export default function NewWorkspaceComposerCard({ )}

) : null} - {readyProjectHostSetupOptions.length > 1 || ephemeralVmRecipes.length > 0 ? ( + {shouldShowRunTargetPicker ? (
{ expect(options.some((o) => String(o.hostId).includes('runtime-ssh-'))).toBe(false) }) + it('omits hidden host categories from setup-needed choices', () => { + const runtimeSshHostId = 'ssh:runtime-ssh-orca-e37aa3a9' as ExecutionHostId + const ephemeralHostId = 'runtime:90d880b2-de1b-44be-b7b8-8e15274e184e' as ExecutionHostId + + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('local-repo')], + hosts: [ + host('local'), + host(runtimeSshHostId), + host(ephemeralHostId, { + source: 'ephemeral-vm', + capabilities: FULL_HOST_MODEL_RUNTIME_CAPABILITIES + }) + ], + projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')] + }) + + expect(options).toEqual([ + expect.objectContaining({ id: 'local', kind: 'ready', label: LOCAL_HOST_LABEL }) + ]) + }) + it('omits setups that are not ready or cannot create through an eligible repo', () => { const options = buildProjectHostSetupOptions({ projectId: 'project-1',