mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
Show setup-needed hosts in workspace run picker
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user