Show setup-needed hosts in workspace run picker

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-08 16:23:55 -04:00
co-authored by Orca
parent ace196b08e
commit f70f920846
3 changed files with 170 additions and 27 deletions
@@ -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<React.ComponentProps<typeof NewWorkspaceComposerCard>> = {}
) {
@@ -195,6 +217,18 @@ function changeInputValue(input: HTMLInputElement, value: string): void {
})
}
function openRunTargetPicker(container: HTMLElement): void {
const runTargetButton = container.querySelector<HTMLButtonElement>('button[role="combobox"]')
expect(runTargetButton).toBeTruthy()
act(() => runTargetButton?.click())
}
function findRunTargetItem(label: string): HTMLElement | undefined {
return [...document.body.querySelectorAll<HTMLElement>('[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<HTMLButtonElement>('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)[] = []
@@ -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.'
)}
</CommandEmpty>
{hostOptions.map((option) => (
<CommandItem
key={option.id}
value={`host:${option.id}`}
onSelect={() => handleHostSelect(option.id)}
className="items-center gap-2 px-3 py-2"
>
<Check
className={cn(
'size-4 text-foreground',
!selectedRecipe && option.id === selectedHost?.id ? 'opacity-100' : 'opacity-0'
)}
/>
<Server className="size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{option.label}</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{option.path}
{hostOptions.map((option) =>
option.kind === 'ready' ? (
<CommandItem
key={option.id}
value={`host:${option.id}`}
onSelect={() => handleHostSelect(option.id)}
className="items-center gap-2 px-3 py-2"
>
<Check
className={cn(
'size-4 text-foreground',
!selectedRecipe && option.id === selectedHost?.id
? 'opacity-100'
: 'opacity-0'
)}
/>
<Server className="size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{option.label}</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{option.path}
</div>
</div>
</div>
</CommandItem>
))}
</CommandItem>
) : (
// Why: setup-on-host is a follow-up; this row only explains why the host cannot
// run the workspace yet.
<CommandItem
key={option.id}
value={option.id}
disabled
className="items-center gap-2 px-3 py-2"
>
<Check className="size-4 opacity-0" />
{option.isAvailable ? (
<Server className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<AlertTriangle className="size-3.5 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{option.label}</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{option.detail}
</div>
</div>
</CommandItem>
)
)}
{recipes.length > 0 ? (
<Popover open={vmRecipesOpen} onOpenChange={setVmRecipesOpen}>
<PopoverTrigger asChild>
@@ -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({
)}
</p>
) : null}
{readyProjectHostSetupOptions.length > 1 || ephemeralVmRecipes.length > 0 ? (
{shouldShowRunTargetPicker ? (
<div className="space-y-1">
<label className="block min-w-0 truncate text-xs font-medium text-muted-foreground">
{translate('auto.components.NewWorkspaceComposerCard.runOn', 'Run on')}
</label>
<WorkspaceRunTargetCombobox
hostOptions={readyProjectHostSetupOptions}
hostOptions={projectHostSetupOptions}
hostValue={selectedProjectHostSetupId ?? null}
onHostChange={handleProjectHostSetupChange}
recipes={ephemeralVmRecipes}
@@ -157,6 +157,29 @@ describe('buildProjectHostSetupOptions', () => {
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',