feat(new-workspace): type-ahead Project and Run-on pickers (#11062)

* feat(new-workspace): make the project picker a type-ahead field

The Create-worktree Project slot read as bulky and unpolished: a label row,
an add-project icon, a 36px outline trigger and a chevron, all spent before
choosing anything — then a popover carrying its own *second* search box,
two-line rows, and a footer that scrolled out of reach.

The field is now the search. Typing filters in place, so the nested search
box is gone. Exactly one row is armed at any time and Enter takes it;
hovering arms, so pointer and keyboard drive one cursor rather than two
competing highlights. Armed is tracked by row key, not index, so a list
arriving late over SSH cannot slide a different project under a keypress
the user already aimed.

Rows are single-line at 28px with an on-row Enter cap that takes space only
while armed, and "Add a new project" is pinned to the popover edge so it
survives every state — scrolled, filtered to nothing, or no projects at all.

Long names and deep paths degrade deliberately: the name keeps up to half
the row and the path elides from its middle, so two monorepo siblings stay
distinguishable as …/services/checkout-api vs -web where a flat truncate
rendered both identically.

Recency is derived from when each project last had a workspace created,
which is the action this picker is about to repeat — no new store field.

The shell keeps data-project-combobox-root + role=combobox and stays
focusable, so the composer's initial-focus and project-required handlers
still land on it.

* fix(new-workspace): align, scroll and loosen the project picker

Five fixes to the type-ahead picker, three reported and two found while
checking for related breakage.

Alignment: the name and its smaller detail line were centred as boxes, so
the 12px path sat visibly high against the 14px name. Both now share a
baseline, in the committed field and in every row. The dot mark and the
Enter cap are chips rather than text, so they stay centred on the row.

Scrolling: the mouse wheel did nothing over the list. The composer is a
Radix Dialog, and react-remove-scroll cancels wheel events for portaled
content outside the dialog's DOM tree — the scrollbar dragged fine but the
wheel was dead. The old cmdk list carried a shim for exactly this; the
plain scroll pane that replaced it did not, so it has its own now.

Density: rows go 28px -> 32px, row text 13px -> 14px and detail 11px ->
12px, with a taller Add row and more air above section headings.

Escape stranded a query: with the list closed but text still typed, Escape
was ignored (it was gated on the list being open), leaving the field
showing text that matched nothing and hid the committed project. Escape now
always restores the committed display, and only bubbles when there is
nothing to undo.

Listbox ownership: options sat inside unroled section and scroll wrappers,
which breaks the listbox -> option relationship assistive tech relies on.
Sections are groups carrying the heading as their label, and the scroll
pane is presentational.

Both new behaviours are covered by tests verified to fail without the fix.

* chore(tools): keep the project-picker design lab

The exploration harness behind the picker rewrite: 16 interactive design
variants rendered against the app's real tokens and shadcn primitives, so a
prototype is a drop-in ProjectCombobox rather than a mockup.

Worth keeping because the frames encode bugs that only reproduce in
context. DialogFrame renders the picker inside a real Radix Dialog, which
is the only way the react-remove-scroll wheel bug shows up; the fixtures
carry duplicate display names and deep sibling paths that a naive truncate
renders identically.

Run with: npx vite --config tools/wt-picker-lab/vite.config.ts

* fix(new-workspace): stop the project list flashing open, shrink its empty state

Opening the picker read as a double flash. The shared popover surface is
translucent and fades 0 -> 1, which is right over the app canvas but wrong
here: this popover lands directly on the composer dialog, so for the length
of the fade the Name field underneath showed straight through the list and
you saw two layers at once. The list now uses an opaque surface and zooms
without fading, so it is solid from the first frame. Every other popover
keeps the blur and fade.

The "No projects match your search." state was a 60px centred block sitting
next to 32px rows, which read as a different kind of surface and made an
empty result feel like an error. It is now sized and aligned like a row.

The lab's dialog frame focused whatever Radix picked first, which popped the
Add-project tooltip on open and masked the real problem; it now focuses the
name field the way the real composer does.

* fix(new-workspace): square mark, centred empty state, and keep the list open on tab-focus

Four fixes, three reported and one found while sweeping for others.

Square mark: the option dot had a `rounded-full` override, so a project read
as a circle here and a square everywhere else (jump palette, sidebar). Drop
the override and use RepoBadgeMark's own shape.

Centred empty state: "No projects match your search." was left-aligned after
being shrunk to row height; centre it.

Tab-focus blinked the list shut: the field lives in the popover's anchor, not
inside its content, so Radix's dismissable layer saw focus land "outside" and
closed the list the instant you tabbed in. Focus and pointer events within
this control no longer dismiss it; genuine outside events still do.

Junk text could strand the field: typing a query that matched nothing and
then clicking away left the text sitting there with the list closed, showing
no project and no error. A query only means something while the list is open,
so closing without committing now clears it.

On pressing Create with no project: no change needed. The create gate has not
depended on project selection since #4991, and both submit paths already call
showProjectRequiredError(), which sets the inline message and turns the field
red via aria-invalid. Verified end to end: the button is pressable, the press
paints the field destructive, and the message appears beneath it.

* feat(new-workspace): rebuild the Run-on picker to match the project picker

"Run on" was the last composer field still built the old way: an outline
trigger wrapping a cmdk list, two-line rows, and no way to search. It now
matches the project picker, so the two fields in the same form read as one
control.

The field is the search — type to filter hosts, paths and recipes with no
nested search box. Exactly one row is armed at a time and Enter takes it;
hovering arms, so pointer and keyboard drive one cursor. Rows are 32px with
the label and its path on a shared baseline, the path eliding from its
middle so two deep sibling paths stay distinguishable. The popover surface
is opaque and unfaded because it lands on the composer dialog, where a
translucent fade shows the form underneath.

Two behaviours the project picker doesn't have are preserved. Disconnected
hosts keep their inline Connect action, tracked per host so one stalled
connect never blocks the others, and the list stays open so the connecting
state is visible. Two rows open nested lists rather than committing: VM
recipes, and "Add host" pinned to the popover edge so it survives every
state — scrolled, filtered to nothing, or with no hosts at all. Enter and
ArrowRight open a submenu; Escape backs out one layer at a time.

Extracted from NewWorkspaceComposerCard (-563 lines) into files that each
stay under the line limit without a suppression.

Tests: the run-target cases asserted cmdk internals (`[cmdk-item]`,
aria-disabled, cmdk-separator) that no longer exist. Rewritten against
behaviour and the listbox roles instead. All 22 composer tests pass,
plus a live sweep of 11 interactions in a real dialog.

* fix(new-workspace): drop the Enter cap, fix submenu hover, match the Add rows

Three follow-ups on the two composer pickers.

The ↵ cap on the hovered row is gone from both. On a run-target row it sat
next to the Connect action and read as a second, competing affordance; the
highlight already says what Enter will take.

Submenu rows never highlighted under the pointer. They passed a hardcoded
`armed={false}`, so the recipe list and the Add-host choices were the only
rows in either picker with no hover state. They now track their own hover.

"Add a new project" used a chunky FolderPlus where "Add host" uses a plain
Plus. Both rows were already the same height and type, so matching the glyph
is the whole difference.

* fix(new-workspace): restore the folder glyph, two-line Add-host cards, quiet Connect rows

Three follow-ups.

"Add a new project" goes back to FolderPlus — matching "Add host"'s plain
Plus made the two consistent but lost the glyph that says which kind of
thing is being added.

A disconnected host row no longer repeats its status. The Connect button
already says the host isn't connected, so "Connect this host to set up
projects" beside it was saying it twice. Rows without a Connect action keep
their detail, since there it explains why the host can't run.

The Add-host choices go back to two-line cards. Their descriptions explain
what you're picking ("Use an existing machine over SSH" vs "Pair another
Orca runtime"), unlike a host row's detail, which just labels a host you
already recognise. RunTargetRow grows a `stacked` variant for that rather
than making the single-line row do both jobs.

* fix(new-workspace): give Run on the same vertical rhythm as the other fields

Run on is nested inside the Project block so the two share its error and
empty states, which also put it on that block's 4px internal spacing. It
reads as its own field, so it sat noticeably tighter than the 16px gap
every other field in the composer gets. Pad it to match.

* refactor(new-workspace): share the type-ahead machinery between both pickers

Project and Run on were built one after the other, so each grew its own copy
of the same mechanics: query and open state, arming by row key, the
arrow-key walk, scroll-the-armed-row-into-view, the react-remove-scroll
wheel shim, and the closes-drops-the-query rule. Two copies of subtle
behaviour is two places for it to drift.

useTypeAheadCombobox now owns all of it. Callers pass a function that turns
a query into row keys and get back the query, the armed key, and the
movement helpers. Run on layers its submenu state on top by wrapping
`close`, which is the only part that isn't shared.

The two long class strings both files repeated verbatim — the field shell
and the opaque unfaded popover surface — are named constants now, so the
reason they differ from the stock popover recipe is written down once
instead of implied by a duplicated literal.

No behaviour change: 16,456 renderer tests pass, plus the 22-check live
interaction sweep across both pickers in a real dialog.

* fix(new-workspace): drop aria-expanded from option rows, remove the design lab

`aria-expanded` isn't a supported prop on `role="option"`, so the submenu
rows were claiming a state screen readers can't interpret there.
`aria-haspopup` alone already says the row opens a menu.

Removes tools/wt-picker-lab. It was the harness for exploring this redesign
— 12 interactive variants — and it did its job, but the 11 that lost are
dead code, and its prototypes were the only thing failing the react-doctor
gate (5 errors, all in throwaway variants; the shipped pickers had none).
This commit is contained in:
Neil 2026-07-27 22:04:00 -07:00 committed by GitHub
parent 89968a1061
commit e73b1a1dd0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 2295 additions and 830 deletions

View File

@ -280,15 +280,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())
// The field is the search box; clicking its shell focuses it and opens the list.
const runTargetShell = container.querySelector<HTMLElement>(
'div[data-run-target-combobox-root="true"]'
)
expect(runTargetShell).toBeTruthy()
act(() => runTargetShell?.click())
}
function findRunTargetItem(label: string): HTMLElement | undefined {
// Why: "Add host" is a pinned footer button (mirrors the Project combobox), not a cmdk row.
// Rows are listbox options; "Add host" is the pinned footer row (also an option).
return [
...document.body.querySelectorAll<HTMLElement>('[cmdk-item], [data-run-target-add-host]')
...document.body.querySelectorAll<HTMLElement>('[role="option"], [data-run-target-add-host]')
].find((item) => item.textContent?.includes(label))
}
@ -579,11 +582,10 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
const devboxItem = findRunTargetItem('Devbox')
expect(devboxItem?.textContent).toContain('Project not set up on this host')
// Not-connected rows stay highlightable (not disabled) so they hover like the other
// items; a separator sets them off instead of a heading.
expect(devboxItem?.getAttribute('aria-disabled')).toBe('false')
expect(devboxItem?.getAttribute('data-disabled')).toBe('false')
expect(document.body.querySelector('[cmdk-separator]')).toBeTruthy()
// Not-connected rows stay highlightable (never `disabled`) so they hover like
// the other rows; they're quieted visually instead.
expect(devboxItem?.hasAttribute('data-disabled')).toBe(false)
expect(devboxItem?.getAttribute('role')).toBe('option')
})
it('shows the run target picker for one ready setup so hosts can be added', () => {
@ -623,10 +625,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
openRunTargetPicker(current.container)
const devboxItem = findRunTargetItem('Devbox')
expect(
devboxItem?.getAttribute('aria-disabled') === 'true' ||
devboxItem?.hasAttribute('data-disabled')
).toBe(true)
expect(devboxItem).toBeTruthy()
const connectButton = [...(devboxItem?.querySelectorAll('button') ?? [])].find((button) =>
button.textContent?.includes('Connect')
)
@ -723,10 +722,10 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
openRunTargetPicker(current.container)
const addHost = findRunTargetItem('Add host')
expect(addHost).toBeTruthy()
// Hovering the row (no click) opens its submenu so it feels like a menu. React derives
// onPointerEnter from a bubbling pointerover, which is what jsdom dispatches here.
// Hovering the row (no click) opens its submenu so it feels like a menu.
// Hover arms rows via mousemove, matching the project picker.
act(() => {
addHost?.dispatchEvent(new MouseEvent('pointerover', { bubbles: true }))
addHost?.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
})
expect(findRunTargetItem('Add SSH host')).toBeTruthy()
@ -785,10 +784,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
expect(current.container.textContent).toContain('Run on')
expect(current.container.textContent).not.toContain('VM recipe')
const runTargetButton =
current.container.querySelector<HTMLButtonElement>('button[role="combobox"]')
expect(runTargetButton).toBeTruthy()
act(() => runTargetButton?.click())
openRunTargetPicker(current.container)
expect(document.body.textContent).toContain('Per-Workspace Environment')
const ephemeralVmItem = [
@ -797,7 +793,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
expect(ephemeralVmItem).toBeTruthy()
act(() => ephemeralVmItem?.click())
const recipeItem = [...document.body.querySelectorAll<HTMLElement>('[cmdk-item]')].find(
const recipeItem = [...document.body.querySelectorAll<HTMLElement>('[role="option"]')].find(
(item) => item.textContent?.includes('Vercel Sandbox')
)
expect(recipeItem).toBeTruthy()
@ -839,12 +835,13 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
onEphemeralVmRecipeChange: (recipeId) => recipeChanges.push(recipeId)
})
const runTargetButton =
current.container.querySelector<HTMLButtonElement>('button[role="combobox"]')
expect(runTargetButton?.textContent).toContain('Per-Workspace Environment')
act(() => runTargetButton?.click())
const runTargetShell = current.container.querySelector<HTMLElement>(
'div[data-run-target-combobox-root="true"]'
)
expect(runTargetShell?.textContent).toContain('Per-Workspace Environment')
openRunTargetPicker(current.container)
const builderItem = [...document.body.querySelectorAll<HTMLElement>('[cmdk-item]')].find(
const builderItem = [...document.body.querySelectorAll<HTMLElement>('[role="option"]')].find(
(item) => item.textContent?.includes('Builder')
)
expect(builderItem).toBeTruthy()

View File

@ -1,33 +1,18 @@
/* eslint-disable max-lines -- Why: keep the full composer card markup together so the inline and modal variants share one UI surface. */
import React from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
AlertTriangle,
Check,
ChevronDown,
ChevronRight,
ChevronsUpDown,
Cloud,
CornerDownLeft,
FolderPlus,
LoaderCircle,
Monitor,
PlugZap,
Plus,
Settings2,
Server
Settings2
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandItem,
CommandList,
CommandSeparator
} from '@/components/ui/command'
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { SettingsSwitch } from '@/components/settings/SettingsFormControls'
import type RepoCombobox from '@/components/repo/RepoCombobox'
@ -63,6 +48,7 @@ import SmartWorkspaceNameField, {
} from '@/components/new-workspace/SmartWorkspaceNameField'
import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-source-results'
import ProjectCombobox from '@/components/new-workspace/ProjectCombobox'
import RunTargetCombobox from '@/components/new-workspace/RunTargetCombobox'
import {
AddRemoteHostDialog,
type AddRemoteHostMode
@ -71,11 +57,9 @@ import type { SetupConfig } from '@/lib/new-workspace'
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
import type {
NeedsSetupProjectHostOption,
ProjectHostSetupOption,
ReadyProjectHostSetupOption
ProjectHostSetupOption
} from '@/lib/project-host-setup-options'
import type { WorkspaceCreateErrorDisplay } from '@/lib/workspace-create-error-format'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
import type { SshConnectionStatus } from '../../../shared/ssh-types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import type { RuntimeStatus } from '../../../shared/runtime-types'
@ -249,549 +233,6 @@ async function withUiConnectTimeout<T>(promise: Promise<T>): Promise<T> {
}
}
function getRecipeCommandDisplay(command: string): string {
const trimmed = command.trim()
const quoted = trimmed.match(/^"([^"]+)"/) ?? trimmed.match(/^'([^']+)'/)
return quoted?.[1] ?? trimmed.split(/\s+/)[0] ?? trimmed
}
function getRecipeDestroyLabel(recipe: EphemeralVmRecipeOption): string {
if (recipe.destroyDisabled) {
return translate('auto.components.NewWorkspaceComposerCard.destroyDisabled', 'destroy disabled')
}
if (recipe.destroy) {
return translate(
'auto.components.NewWorkspaceComposerCard.destroyConfigured',
'destroy configured'
)
}
return translate('auto.components.NewWorkspaceComposerCard.noDestroyConfigured', 'no destroy')
}
type WorkspaceRunTargetComboboxProps = {
hostOptions: readonly ProjectHostSetupOption[]
hostValue: string | null
onHostChange?: (setupId: string) => void
recipes: EphemeralVmRecipeOption[]
recipeValue: string | null
onRecipeChange?: (recipeId: string | null) => void
onAddRemoteServer?: () => void
onAddSshHost?: () => void
onConnectHost?: (option: NeedsSetupProjectHostOption) => Promise<void> | void
}
type HostPathTooltipPosition = {
left: number
top: number
maxWidth: number
}
const HOST_PATH_TOOLTIP_DELAY_MS = 400
const HOST_PATH_TOOLTIP_VIEWPORT_GAP_PX = 8
const HOST_PATH_TOOLTIP_TRIGGER_GAP_PX = 4
function HostPathTooltip({ path }: { path: string }): React.JSX.Element {
const tooltipId = React.useId()
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const pointerInsideRef = React.useRef(false)
const [position, setPosition] = React.useState<HostPathTooltipPosition | null>(null)
const hideTooltip = React.useCallback((): void => {
pointerInsideRef.current = false
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
timerRef.current = null
}
setPosition(null)
}, [])
React.useEffect(() => hideTooltip, [hideTooltip])
const handlePointerEnter = React.useCallback((event: React.PointerEvent<HTMLElement>): void => {
pointerInsideRef.current = true
const trigger = event.currentTarget
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
}
timerRef.current = setTimeout(() => {
timerRef.current = null
if (!pointerInsideRef.current || !trigger.isConnected) {
return
}
const rect = trigger.getBoundingClientRect()
// Why: anchor under the hovered path, capping width to the viewport edge so a long path wraps instead of flying off-screen.
const left = Math.max(HOST_PATH_TOOLTIP_VIEWPORT_GAP_PX, rect.left)
setPosition({
left,
top: rect.bottom + HOST_PATH_TOOLTIP_TRIGGER_GAP_PX,
maxWidth: window.innerWidth - left - HOST_PATH_TOOLTIP_VIEWPORT_GAP_PX
})
}, HOST_PATH_TOOLTIP_DELAY_MS)
}, [])
return (
<>
{/* Trigger is the truncated path line itself, so the tooltip only appears when hovering it. */}
<div
className="mt-0.5 truncate text-[11px] text-muted-foreground"
aria-describedby={position ? tooltipId : undefined}
onPointerEnter={handlePointerEnter}
onPointerLeave={hideTooltip}
onPointerDown={hideTooltip}
>
{path}
</div>
{/* Why: a fixed, pointer-transparent portal cannot reflow cmdk or become the hover target. */}
{position
? createPortal(
<div
id={tooltipId}
role="tooltip"
data-slot="host-path-tooltip"
className="pointer-events-none fixed z-[100] w-max break-all rounded-sm border border-border bg-popover px-1.5 py-1 font-mono text-[11px] leading-tight text-popover-foreground shadow-xs"
style={{ left: position.left, top: position.top, maxWidth: position.maxWidth }}
>
{path}
</div>,
document.body
)
: null}
</>
)
}
// Why: the local machine isn't a server — give it a monitor glyph so it reads as "this computer".
function HostRowIcon({ hostId }: { hostId: ExecutionHostId }): React.JSX.Element {
const Icon = hostId === LOCAL_EXECUTION_HOST_ID ? Monitor : Server
return <Icon className="size-3.5 shrink-0 text-muted-foreground" />
}
function WorkspaceRunTargetCombobox({
hostOptions,
hostValue,
onHostChange,
recipes,
recipeValue,
onRecipeChange,
onAddRemoteServer,
onAddSshHost,
onConnectHost
}: WorkspaceRunTargetComboboxProps): React.JSX.Element {
const [open, setOpen] = React.useState(false)
const [vmRecipesOpen, setVmRecipesOpen] = React.useState(false)
const [hostActionsOpen, setHostActionsOpen] = React.useState(false)
// Why: track in-flight connects per host (a Set, not a single id) so connecting one host — or
// one connect stalling — never disables connecting to the others.
const [connectingHostIds, setConnectingHostIds] = React.useState<ReadonlySet<string>>(
() => new Set()
)
const readyHostOptions = React.useMemo(
() =>
hostOptions.filter(
(option): option is ReadyProjectHostSetupOption => option.kind === 'ready'
),
[hostOptions]
)
const needsSetupHostOptions = React.useMemo(
() =>
hostOptions.filter(
(option): option is NeedsSetupProjectHostOption => option.kind === 'needs-setup'
),
[hostOptions]
)
const selectedHost =
readyHostOptions.find((option) => option.id === hostValue) ?? readyHostOptions[0] ?? null
const selectedRecipe = recipes.find((recipe) => recipe.id === recipeValue) ?? null
const selectedValue = selectedRecipe
? `recipe:${selectedRecipe.id}`
: selectedHost
? `host:${selectedHost.id}`
: ''
const ephemeralVmLabel = translate(
'auto.components.NewWorkspaceComposerCard.ephemeralVm',
'Per-Workspace Environment'
)
const handleHostSelect = React.useCallback(
(setupId: string): void => {
if (!readyHostOptions.some((candidate) => candidate.id === setupId)) {
return
}
onHostChange?.(setupId)
onRecipeChange?.(null)
setOpen(false)
},
[onHostChange, onRecipeChange, readyHostOptions]
)
const handleRecipeSelect = React.useCallback(
(recipeId: string): void => {
if (!recipes.some((recipe) => recipe.id === recipeId)) {
return
}
onRecipeChange?.(recipeId)
setVmRecipesOpen(false)
setOpen(false)
},
[onRecipeChange, recipes]
)
const connectHost = React.useCallback(
async (option: NeedsSetupProjectHostOption): Promise<void> => {
if (!option.connectAction || !onConnectHost || connectingHostIds.has(option.hostId)) {
return
}
setConnectingHostIds((current) => new Set(current).add(option.hostId))
try {
await onConnectHost(option)
} finally {
// Why: always clear the in-flight id when the connect settles (success, failure, or
// timeout) so the row's spinner stops. No mounted-guard — a stale setState on an
// unmounted component is a harmless no-op in React 18, and the guard's ref was the
// source of a StrictMode bug that left the spinner stuck.
setConnectingHostIds((current) => {
if (!current.has(option.hostId)) {
return current
}
const next = new Set(current)
next.delete(option.hostId)
return next
})
}
},
[connectingHostIds, onConnectHost]
)
// Why: the submenu rows open their popover on hover/focus (not just click) to feel like a
// menu; opening one closes the other so the two right-side popovers never stack.
const openVmRecipesSubmenu = React.useCallback((): void => {
setHostActionsOpen(false)
setVmRecipesOpen(true)
}, [])
const openHostActionsSubmenu = React.useCallback((): void => {
setVmRecipesOpen(false)
setHostActionsOpen(true)
}, [])
// Why: hovering/highlighting a plain host row dismisses any submenu popover left open from
// passing over the recipe/add-host rows, so a stray submenu can't linger over the list.
const closeSubmenus = React.useCallback((): void => {
setVmRecipesOpen(false)
setHostActionsOpen(false)
}, [])
const handleAddSshHost = React.useCallback((): void => {
setHostActionsOpen(false)
setOpen(false)
onAddSshHost?.()
}, [onAddSshHost])
const handleAddRemoteServer = React.useCallback((): void => {
setHostActionsOpen(false)
setOpen(false)
onAddRemoteServer?.()
}, [onAddRemoteServer])
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className="h-9 w-full justify-between border-input px-3 text-sm font-normal focus:border-ring focus:ring-[3px] focus:ring-ring/50"
>
{selectedRecipe ? (
<span className="inline-flex min-w-0 items-center gap-1.5">
<Cloud className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">
{ephemeralVmLabel} / {selectedRecipe.name}
</span>
</span>
) : selectedHost ? (
<span className="inline-flex min-w-0 items-center gap-1.5">
<HostRowIcon hostId={selectedHost.hostId} />
<span className="truncate">{selectedHost.label}</span>
</span>
) : (
<span className="text-muted-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.chooseRunTarget',
'Choose target'
)}
</span>
)}
<ChevronsUpDown className="size-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] min-w-[18rem] p-0"
>
<Command value={selectedValue}>
<CommandList>
<CommandEmpty>
{translate(
'auto.components.NewWorkspaceComposerCard.noRunTargets',
'No run targets are ready for this project.'
)}
</CommandEmpty>
{readyHostOptions.map((option) => (
<CommandItem
key={option.id}
value={`host:${option.id}`}
onSelect={() => handleHostSelect(option.id)}
onPointerEnter={closeSubmenus}
onFocus={closeSubmenus}
className="items-center gap-2 px-3 py-1.5"
>
<Check
className={cn(
'size-4 text-foreground',
!selectedRecipe && option.id === selectedHost?.id ? 'opacity-100' : 'opacity-0'
)}
/>
<HostRowIcon hostId={option.hostId} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{option.label}</div>
<HostPathTooltip path={option.path} />
</div>
</CommandItem>
))}
{/* Why: not-connected hosts are dormant, not errors a separator (no heading) sets
them off as a secondary group without labeling the recipe/add-host rows below. */}
{needsSetupHostOptions.length > 0 ? (
<>
<CommandSeparator />
{needsSetupHostOptions.map((option) => (
// Why: setup-on-host is a follow-up; this row only explains why the host cannot
// run the workspace yet. It stays highlightable (no `disabled`) so it matches the
// hover of the other rows, but selecting it is a no-op since it isn't ready.
<CommandItem
key={option.id}
value={option.id}
onSelect={() => {}}
onPointerEnter={closeSubmenus}
onFocus={closeSubmenus}
className="items-center gap-2 px-3 py-1.5"
>
<div className="flex min-w-0 flex-1 items-center gap-2 opacity-60">
<Check className="size-4 opacity-0" />
{/* Why: show a spinner while connecting; otherwise reserve the alarm glyph for
a genuine connection error and give a dormant disconnected host the neutral
server icon. */}
{connectingHostIds.has(option.hostId) ? (
<LoaderCircle className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
) : option.attention ? (
<AlertTriangle className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<HostRowIcon hostId={option.hostId} />
)}
<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>
</div>
{option.connectAction && onConnectHost ? (
<Button
type="button"
variant="ghost"
size="xs"
className="ml-1 shrink-0 gap-1 text-muted-foreground/50 hover:text-muted-foreground"
// Why: only disable the row being connected — not every row — so one
// stuck/slow connect can't lock out connecting to the other hosts.
disabled={connectingHostIds.has(option.hostId)}
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
// Why: keep the picker open so the connecting state stays visible; the
// row updates in place from store SSH state once the connect resolves.
void connectHost(option)
}}
>
{connectingHostIds.has(option.hostId) ? (
<>
<LoaderCircle className="size-3 animate-spin" />
{translate(
'auto.components.NewWorkspaceComposerCard.connectingHost',
'Connecting…'
)}
</>
) : (
translate(
'auto.components.NewWorkspaceComposerCard.connectHost',
'Connect'
)
)}
</Button>
) : null}
</CommandItem>
))}
</>
) : null}
{/* Why: separate the host list from the per-workspace-env row; the "Add host" action
is pinned below the list with its own border, so it needs no separator here. */}
{recipes.length > 0 &&
(readyHostOptions.length > 0 || needsSetupHostOptions.length > 0) ? (
<CommandSeparator />
) : null}
{recipes.length > 0 ? (
<Popover open={vmRecipesOpen} onOpenChange={setVmRecipesOpen}>
<PopoverTrigger asChild>
{/* Why: a real CommandItem (not a raw button) so cmdk registers it — fixes missing rows, uneven height, and double-highlight. */}
<CommandItem
value="per-workspace-env"
onSelect={openVmRecipesSubmenu}
onPointerEnter={openVmRecipesSubmenu}
onFocus={openVmRecipesSubmenu}
className="items-center gap-2 px-3 py-1.5"
>
<Check
className={cn(
'size-4 text-foreground',
selectedRecipe ? 'opacity-100' : 'opacity-0'
)}
/>
<Cloud className="size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{ephemeralVmLabel}</div>
{/* Why: a second line so this row matches the two-line host options above and hints what it opens. */}
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.perWorkspaceEnvHint',
'Provision an on-demand environment from a recipe'
)}
</div>
</div>
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
</CommandItem>
</PopoverTrigger>
<PopoverContent side="right" align="start" sideOffset={6} className="w-72 p-0">
<Command value={selectedRecipe ? `recipe:${selectedRecipe.id}` : ''}>
<CommandList>
{recipes.map((recipe) => (
<CommandItem
key={recipe.id}
value={`recipe:${recipe.id}`}
onSelect={() => handleRecipeSelect(recipe.id)}
className="items-center gap-2 px-3 py-1.5"
>
<Check
className={cn(
'size-4 text-foreground',
recipe.id === selectedRecipe?.id ? 'opacity-100' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{recipe.name}</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{getRecipeCommandDisplay(recipe.create)} ·{' '}
{getRecipeDestroyLabel(recipe)}
</div>
{recipe.description ? (
<div className="truncate text-[11px] text-muted-foreground">
{recipe.description}
</div>
) : null}
</div>
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : null}
</CommandList>
{/* Why: pin "Add host" below the scrollable list mirrors the Project combobox's
"Add a new project" footer so it keeps a compact single-row height and one clean
divider instead of a taller in-list row above the popover edge. */}
<div className="border-t border-border">
<Popover open={hostActionsOpen} onOpenChange={setHostActionsOpen}>
{/* Why: an Anchor (not a Trigger) so click/hover/focus all just open the submenu
a Trigger's own toggle would fight the hover-open and close it on the same click. */}
<PopoverAnchor asChild>
<Button
type="button"
variant="ghost"
data-run-target-add-host="true"
onClick={openHostActionsSubmenu}
onPointerEnter={openHostActionsSubmenu}
onFocus={openHostActionsSubmenu}
className="h-8 w-full justify-start gap-2 rounded-none px-3 text-xs font-normal"
>
<Plus className="size-3.5 shrink-0 text-muted-foreground" />
<span>
{translate('auto.components.NewWorkspaceComposerCard.addHost', 'Add host')}
</span>
<ChevronRight className="ml-auto size-3.5 shrink-0 text-muted-foreground" />
</Button>
</PopoverAnchor>
<PopoverContent side="right" align="start" sideOffset={6} className="w-72 p-0">
{/* Why: pin an empty value so cmdk doesn't auto-highlight the first row on open
matches the recipes submenu, which leaves nothing highlighted by default. */}
<Command value="">
<CommandList>
<CommandItem
value="add-ssh-host"
onSelect={handleAddSshHost}
className="items-center gap-2 px-3 py-1.5"
>
<Server className="size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm">
{translate(
'auto.components.NewWorkspaceComposerCard.addSshHost',
'Add SSH host'
)}
</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.addSshHostHint',
'Use an existing machine over SSH'
)}
</div>
</div>
</CommandItem>
<CommandItem
value="add-remote-orca-server"
onSelect={handleAddRemoteServer}
className="items-center gap-2 px-3 py-1.5"
>
<Cloud className="size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm">
{translate(
'auto.components.NewWorkspaceComposerCard.addRemoteOrcaServer',
'Add Remote Orca Server'
)}
</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.addRemoteOrcaServerHint',
'Pair another Orca runtime'
)}
</div>
</div>
</CommandItem>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</Command>
</PopoverContent>
</Popover>
)
}
function SetupCommandPreview({ setupConfig }: { setupConfig: SetupConfig }): React.JSX.Element {
// Why: just the script in a quiet monochrome card — the source label (orca.yaml / local) and
// the run-setup toggle live in the section header above, so the card carries no chrome of its
@ -1258,11 +699,14 @@ export default function NewWorkspaceComposerCard({
</p>
) : null}
{shouldShowRunTargetPicker ? (
<div className="space-y-1">
// Why: Run on is nested in the Project block (so they share the
// error/empty states), which put it on the block's 4px rhythm. It's
// its own field, so give it the 16px other fields get.
<div className="space-y-1 pt-3">
<label className="block min-w-0 truncate text-xs font-medium text-muted-foreground">
{translate('auto.components.NewWorkspaceComposerCard.runOn', 'Run on')}
</label>
<WorkspaceRunTargetCombobox
<RunTargetCombobox
hostOptions={projectHostSetupOptions}
hostValue={selectedProjectHostSetupId ?? null}
onHostChange={handleProjectHostSetupChange}

View File

@ -6,34 +6,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
import ProjectCombobox from './ProjectCombobox'
// Render the popover inline so assertions can reach the list without a portal.
// `onOpenChange` is exposed on a button so a test can close the popover the way
// Radix does on Escape — independently of the component's own key handler.
vi.mock('@/components/ui/popover', () => ({
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>
}))
vi.mock('@/components/ui/command', () => ({
Command: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CommandEmpty: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CommandInput: React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
(props, ref) => <input ref={ref} {...props} />
),
CommandList: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CommandItem: ({
Popover: ({
children,
onSelect,
value
onOpenChange
}: {
children: React.ReactNode
onSelect?: (value: string) => void
value: string
onOpenChange?: (open: boolean) => void
}) => (
<button type="button" data-command-value={value} onClick={() => onSelect?.(value)}>
<div>
<button type="button" data-test-close-popover onClick={() => onOpenChange?.(false)} />
{children}
</button>
)
</div>
),
PopoverAnchor: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
}))
vi.mock('./use-recent-project-ids', () => ({ useRecentProjectIds: () => [] }))
let container: HTMLDivElement
let root: Root
@ -66,6 +60,41 @@ const projects: NewWorkspaceProjectOption[] = [
}
]
function field(): HTMLInputElement {
const node = container.querySelector<HTMLInputElement>(
'input[data-project-combobox-root="true"][role="combobox"]'
)
if (!node) {
throw new Error('project combobox field not found')
}
return node
}
function openList(): void {
act(() => {
field().dispatchEvent(new FocusEvent('focus', { bubbles: true }))
})
}
function rowFor(optionId: string): HTMLElement {
const row = Array.from(container.querySelectorAll<HTMLElement>('[role="option"]')).find((node) =>
node.textContent?.includes(optionId)
)
if (!row) {
throw new Error(`row not found for ${optionId}`)
}
return row
}
function type(value: string): void {
const input = field()
act(() => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
setter?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
}
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
@ -87,9 +116,24 @@ describe('ProjectCombobox', () => {
)
})
const trigger = container.querySelector('[data-project-combobox-root="true"][role="combobox"]')
expect(trigger?.textContent).toContain('orca')
expect(trigger?.textContent).not.toContain('SSH')
const shell = container.querySelector('[data-project-combobox-root="true"]')
expect(shell?.textContent).toContain('orca')
expect(shell?.textContent).not.toContain('SSH')
})
it('keeps a focusable combobox that composer focus helpers can target', () => {
act(() => {
root.render(<ProjectCombobox options={projects} value={null} onValueChange={vi.fn()} />)
})
const trigger = container.querySelector<HTMLElement>(
'[data-project-combobox-root="true"][role="combobox"]'
)
expect(trigger).toBeTruthy()
act(() => {
trigger?.focus()
})
expect(document.activeElement).toBe(trigger)
})
it('selects projects by logical project id', () => {
@ -104,10 +148,9 @@ describe('ProjectCombobox', () => {
/>
)
})
openList()
act(() => {
container
.querySelector<HTMLButtonElement>('[data-command-value="github:stablyai/noqa"]')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
rowFor('stablyai/noqa').dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(onValueChange).toHaveBeenCalledWith('github:stablyai/noqa')
@ -126,14 +169,13 @@ describe('ProjectCombobox', () => {
)
})
const trigger = container.querySelector('[data-project-combobox-root="true"][role="combobox"]')
expect(trigger?.textContent).toContain('Platform')
const shell = container.querySelector('[data-project-combobox-root="true"]')
expect(shell?.textContent).toContain('Platform')
openList()
expect(container.textContent).toContain('/tmp/platform')
act(() => {
container
.querySelector<HTMLButtonElement>('[data-command-value="project-group:folder-group"]')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
rowFor('/tmp/platform').dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(onValueChange).toHaveBeenCalledWith('project-group:folder-group')
@ -152,23 +194,53 @@ describe('ProjectCombobox', () => {
/>
)
})
openList()
const addButton = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).find(
(button) => button.textContent?.includes('Add a new project')
const addRow = Array.from(container.querySelectorAll<HTMLElement>('[role="option"]')).find(
(node) => node.textContent?.includes('Add a new project')
)
expect(addButton).toBeTruthy()
expect(addRow).toBeTruthy()
act(() => {
addButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
addRow?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(onAddProject).toHaveBeenCalledTimes(1)
})
it('keeps "Add a new project" reachable when the search matches nothing', () => {
const onAddProject = vi.fn()
act(() => {
root.render(
<ProjectCombobox
options={projects}
value={null}
onValueChange={vi.fn()}
onAddProject={onAddProject}
/>
)
})
openList()
type('zzzznomatch')
expect(container.textContent).toContain('No projects match your search.')
const addRow = Array.from(container.querySelectorAll<HTMLElement>('[role="option"]')).find(
(node) => node.textContent?.includes('Add a new project')
)
expect(addRow).toBeTruthy()
act(() => {
addRow?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(onAddProject).toHaveBeenCalledTimes(1)
})
it('omits the "Add a new project" action when no handler is provided', () => {
act(() => {
root.render(<ProjectCombobox options={projects} value={null} onValueChange={vi.fn()} />)
})
openList()
expect(container.textContent).not.toContain('Add a new project')
})
@ -198,8 +270,163 @@ describe('ProjectCombobox', () => {
<ProjectCombobox options={duplicateProjects} value={null} onValueChange={vi.fn()} />
)
})
openList()
expect(container.textContent).toContain('/workspace/storefront/merchant')
expect(container.textContent).toContain('/workspace/admin/merchant')
})
it('filters as the user types, without a second search box', () => {
act(() => {
root.render(<ProjectCombobox options={projects} value={null} onValueChange={vi.fn()} />)
})
openList()
// The field itself is the only text input in the control.
expect(container.querySelectorAll('input')).toHaveLength(1)
type('noq')
expect(container.textContent).toContain('stablyai/noqa')
expect(container.textContent).not.toContain('stablyai/orca')
})
it('commits the armed row on Enter', () => {
const onValueChange = vi.fn()
const onValueSelected = vi.fn()
act(() => {
root.render(
<ProjectCombobox
options={projects}
value={null}
onValueChange={onValueChange}
onValueSelected={onValueSelected}
/>
)
})
openList()
type('noq')
act(() => {
field().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
})
expect(onValueChange).toHaveBeenCalledWith('github:stablyai/noqa')
expect(onValueSelected).toHaveBeenCalledWith('github:stablyai/noqa')
})
it('arms "Add a new project" when a query matches nothing, so Enter is never a wrong guess', () => {
const onAddProject = vi.fn()
const onValueChange = vi.fn()
act(() => {
root.render(
<ProjectCombobox
options={projects}
value={null}
onValueChange={onValueChange}
onAddProject={onAddProject}
/>
)
})
openList()
type('zzzznomatch')
act(() => {
field().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
})
expect(onAddProject).toHaveBeenCalledTimes(1)
expect(onValueChange).not.toHaveBeenCalled()
})
it('restores the committed project on Escape instead of stranding a stale query', () => {
act(() => {
root.render(
<ProjectCombobox options={projects} value="github:stablyai/orca" onValueChange={vi.fn()} />
)
})
openList()
type('zzzznomatch')
expect(field().value).toBe('zzzznomatch')
// Radix closes the popover itself on Escape/outside-click, leaving a closed
// list with a live query — the state that used to strand the field showing
// text matching nothing while hiding the committed project.
act(() => {
container
.querySelector<HTMLButtonElement>('[data-test-close-popover]')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
act(() => {
field().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
expect(field().value).toBe('')
const shell = container.querySelector('[data-project-combobox-root="true"]')
expect(shell?.textContent).toContain('orca')
})
it('drops an uncommitted query when the list closes, so junk text never persists', () => {
act(() => {
root.render(
<ProjectCombobox options={projects} value="github:stablyai/orca" onValueChange={vi.fn()} />
)
})
openList()
type('asasdasd')
expect(field().value).toBe('asasdasd')
// Blur / outside-click closes via Radix, not the key handler.
act(() => {
container
.querySelector<HTMLButtonElement>('[data-test-close-popover]')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(field().value).toBe('')
const shell = container.querySelector('[data-project-combobox-root="true"]')
expect(shell?.textContent).toContain('orca')
})
it('marks the field invalid so a failed create press can turn it red', () => {
act(() => {
root.render(
<ProjectCombobox
options={projects}
value={null}
onValueChange={vi.fn()}
invalid
describedBy="project-error"
/>
)
})
expect(field().getAttribute('aria-invalid')).toBe('true')
expect(field().getAttribute('aria-describedby')).toBe('project-error')
})
it('owns every option from the listbox, with no unroled wrapper in between', () => {
act(() => {
root.render(
<ProjectCombobox
options={projects}
value={null}
onValueChange={vi.fn()}
onAddProject={vi.fn()}
/>
)
})
openList()
const listbox = container.querySelector('[role="listbox"]')
expect(listbox).toBeTruthy()
const options = Array.from(listbox?.querySelectorAll('[role="option"]') ?? [])
expect(options.length).toBeGreaterThan(0)
for (const option of options) {
let parent = option.parentElement
while (parent && parent !== listbox) {
// A bare wrapper here breaks the listbox → option relationship for AT.
expect(parent.getAttribute('role')).toBeTruthy()
parent = parent.parentElement
}
}
})
})

View File

@ -1,21 +1,18 @@
import React, { useCallback, useMemo, useState } from 'react'
import { Check, ChevronsUpDown, FolderOpen, FolderPlus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
import React, { useCallback, useMemo } from 'react'
import { ChevronDown, FolderPlus } from 'lucide-react'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import {
searchNewWorkspaceProjectOptions,
type NewWorkspaceProjectOption
} from '@/lib/new-workspace-project-options'
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
import { translate } from '@/i18n/i18n'
import {
getAmbiguousProjectOptionIds,
rankProjectOptions,
sectionProjectOptions
} from './project-combobox-matching'
import { ProjectOptionDetail, ProjectOptionMark, ProjectOptionRow } from './ProjectComboboxRow'
import { useRecentProjectIds } from './use-recent-project-ids'
import { isWithinComboboxRoot, useTypeAheadCombobox } from './use-type-ahead-combobox'
import { COMBOBOX_FIELD_SHELL, COMBOBOX_POPOVER_SURFACE } from './type-ahead-combobox-styles'
type ProjectComboboxProps = {
options: readonly NewWorkspaceProjectOption[]
@ -29,6 +26,16 @@ type ProjectComboboxProps = {
describedBy?: string
}
const ADD_PROJECT_KEY = 'add-project'
const ROOT_ATTRIBUTE = 'data-project-combobox-root'
/**
* Type-ahead project picker: the field *is* the search, so there's no trigger
* wrapping a second search box. Exactly one row is armed at any time and Enter
* takes it; hovering arms, so the pointer and the keyboard drive one cursor.
* "Add a new project" is pinned to the popover edge so it stays reachable
* without scrolling, in every state including no-matches and no-projects.
*/
export default function ProjectCombobox({
options,
value,
@ -40,217 +47,303 @@ export default function ProjectCombobox({
invalid = false,
describedBy
}: ProjectComboboxProps): React.JSX.Element {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [commandValue, setCommandValue] = useState('')
const inputRef = React.useRef<HTMLInputElement | null>(null)
const focusFrameRef = React.useRef<number | null>(null)
const selectedProject = useMemo(
() => options.find((option) => option.id === value) ?? null,
[options, value]
const recentIds = useRecentProjectIds()
// Ranking depends on the query the hook owns, so rows are derived from it and
// handed back; `matches`/`sections` are recomputed from the same query below.
const deriveRowKeys = useCallback(
(query: string): string[] => [
...rankProjectOptions(options, query, recentIds).map((match) => match.option.id),
...(onAddProject ? [ADD_PROJECT_KEY] : [])
],
[onAddProject, options, recentIds]
)
const filteredOptions = useMemo(
() => searchNewWorkspaceProjectOptions(options, query),
[options, query]
const {
query,
setQuery,
open,
setOpen,
close,
handleOpenChange,
armedKey,
arm,
moveArm,
inputRef,
listId,
setListNode
} = useTypeAheadCombobox(deriveRowKeys)
const matches = useMemo(
() => rankProjectOptions(options, query, recentIds),
[options, query, recentIds]
)
const cancelFocusFrame = useCallback((): void => {
if (focusFrameRef.current !== null) {
cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
}
}, [])
const setInputNode = useCallback(
(node: HTMLInputElement | null): void => {
if (node === null) {
cancelFocusFrame()
}
inputRef.current = node
},
[cancelFocusFrame]
const sections = useMemo(
() => sectionProjectOptions(matches, query, recentIds),
[matches, query, recentIds]
)
const ambiguous = useMemo(() => getAmbiguousProjectOptionIds(options), [options])
const selected = options.find((option) => option.id === value) ?? null
// A committed pick shows as the field's own content; typing replaces it.
const committed = selected !== null && query.length === 0
const focusSearchInput = useCallback((): void => {
cancelFocusFrame()
focusFrameRef.current = requestAnimationFrame(() => {
focusFrameRef.current = null
inputRef.current?.focus()
})
}, [cancelFocusFrame])
const handleOpenChange = useCallback(
(nextOpen: boolean): void => {
setOpen(nextOpen)
if (nextOpen) {
setCommandValue(value ?? '')
const commit = useCallback(
(key: string | null): void => {
if (key === null) {
return
}
cancelFocusFrame()
setQuery('')
},
[cancelFocusFrame, value]
)
const handleSelect = useCallback(
(projectId: string): void => {
onValueChange(projectId)
setOpen(false)
setQuery('')
onValueSelected?.(projectId)
},
[onValueChange, onValueSelected]
)
const handleAddProject = useCallback((): void => {
setOpen(false)
setQuery('')
onAddProject?.()
}, [onAddProject])
const handleTriggerKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>): void => {
if (open) {
close()
if (key === ADD_PROJECT_KEY) {
onAddProject?.()
return
}
onValueChange(key)
onValueSelected?.(key)
},
[close, onAddProject, onValueChange, onValueSelected]
)
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
setCommandValue(value ?? '')
setOpen(true)
moveArm(event.key === 'ArrowDown' ? 1 : -1)
return
}
if (event.metaKey || event.ctrlKey || event.altKey) {
return
}
if (event.key.length === 1 && /\S/.test(event.key)) {
if (event.key === 'Enter' && open) {
event.preventDefault()
setCommandValue(value ?? '')
setQuery(event.key)
commit(armedKey)
return
}
// Why: not gated on `open` — a leftover query with the list closed would
// otherwise strand the field showing text that matches nothing and hides
// the committed project. Escape always restores the committed display,
// and only bubbles (to close the dialog) when there's nothing to undo.
if (event.key === 'Escape' && (open || query.length > 0)) {
event.preventDefault()
event.stopPropagation()
close()
return
}
// Backspace on a committed pick unsticks it back into editable text.
if (event.key === 'Backspace' && committed && selected) {
event.preventDefault()
setQuery(selected.displayName)
setOpen(true)
}
},
[open, value]
[armedKey, close, commit, committed, moveArm, open, query, selected, setOpen, setQuery]
)
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
aria-invalid={invalid ? true : undefined}
aria-describedby={describedBy}
onKeyDown={handleTriggerKeyDown}
<PopoverAnchor asChild>
<div
data-project-combobox-root="true"
onClick={() => {
inputRef.current?.focus()
setOpen(true)
}}
className={cn(
'h-8 min-w-[184px] justify-between px-3 text-xs font-normal',
COMBOBOX_FIELD_SHELL,
invalid && 'border-destructive ring-destructive/20 dark:ring-destructive/40',
triggerClassName
)}
data-project-combobox-root="true"
>
{selectedProject ? (
selectedProject.kind === 'project-group' ? (
<span className="flex min-w-0 items-center gap-1.5">
<FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{selectedProject.displayName}</span>
</span>
) : (
<RepoBadgeLabel
name={selectedProject.displayName}
color={selectedProject.badgeColor}
badgeClassName="size-1.5"
/>
)
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
<ChevronsUpDown className="size-3.5 opacity-50" />
</Button>
</PopoverTrigger>
<span className="flex w-4 shrink-0 items-center justify-center">
{committed && selected ? <ProjectOptionMark option={selected} /> : null}
</span>
<div className="relative min-w-0 flex-1 overflow-hidden">
<input
ref={inputRef}
type="text"
role="combobox"
data-project-combobox-root="true"
aria-label={translate(
'auto.components.new.workspace.ProjectCombobox.label',
'Project'
)}
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={open && armedKey ? `${listId}-armed` : undefined}
aria-invalid={invalid ? true : undefined}
aria-describedby={describedBy}
value={query}
placeholder={committed ? '' : placeholder}
onChange={(event) => {
setQuery(event.target.value)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
className={cn(
'w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground',
committed && 'text-transparent caret-foreground'
)}
/>
{/* Painted over the input so a long name and its path can shrink at
different rates instead of truncating as one flat string. */}
{committed && selected ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center text-sm"
>
{/* Why: the outer row centres this group in the field, while the
group itself is baseline-aligned centring two different
type sizes leaves the smaller one sitting visibly high. */}
<div className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 max-w-[50%] shrink truncate">
{selected.displayName}
</span>
<ProjectOptionDetail
detail={selected.detail}
className="min-w-0 flex-1 shrink-[999] justify-end text-xs text-muted-foreground"
/>
</div>
</div>
) : null}
</div>
<button
type="button"
tabIndex={-1}
aria-label={translate(
'auto.components.new.workspace.ProjectCombobox.browse',
'Browse projects'
)}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
inputRef.current?.focus()
setOpen(!open)
}}
className="-mr-1 flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ChevronDown className={cn('size-3.5 transition-transform', open && 'rotate-180')} />
</button>
</div>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] min-w-[18rem] p-0"
data-project-combobox-root="true"
onOpenAutoFocus={(event) => {
event.preventDefault()
focusSearchInput()
sideOffset={4}
// Why opaque + no fade: this popover lands directly on the composer
// dialog, not the app canvas. The shared surface is translucent and
// fades 0→1, so mid-animation the Name field underneath reads straight
// through the list — two layers at once, which is the "double flash".
// An opaque surface that zooms without fading resolves it. (`bg-popover`
// alone loses to the primitive's arbitrary-value background, hence the
// matching arbitrary form.) Every other caller keeps the blur and fade.
className={cn(
'flex w-[var(--radix-popover-trigger-width)] min-w-[17rem] flex-col p-0',
COMBOBOX_POPOVER_SURFACE
)}
// Focus stays in the field — it's the search box — so the popover must
// not steal it on open, nor yank it back on close after a pick.
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
// Why: the field lives in the anchor, not inside the content, so Radix
// sees a focus/pointer event "outside" the layer and dismisses it the
// instant you tab in. Keep the layer open whenever the interaction is
// within this control; genuine outside events still close it.
onFocusOutside={(event) => {
if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) {
event.preventDefault()
}
}}
onInteractOutside={(event) => {
if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) {
event.preventDefault()
}
}}
>
<Command shouldFilter={false} value={commandValue} onValueChange={setCommandValue}>
<CommandInput
ref={setInputNode}
placeholder={translate(
'auto.components.new.workspace.ProjectCombobox.search',
'Search projects...'
)}
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>
{translate(
'auto.components.new.workspace.ProjectCombobox.empty',
'No projects match your search.'
)}
</CommandEmpty>
{filteredOptions.map((option) => (
<CommandItem
key={option.id}
value={option.id}
onSelect={() => handleSelect(option.id)}
className="items-center gap-2 px-3 py-1.5"
>
<Check
className={cn(
'size-4 text-foreground',
option.id === value ? 'opacity-100' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
{option.kind === 'project-group' ? (
<div className="flex min-w-0 items-center gap-1.5 text-sm">
<FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{option.displayName}</span>
</div>
) : (
<RepoBadgeLabel
name={option.displayName}
color={option.badgeColor}
className="max-w-full"
/>
)}
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">
{option.detail}
</p>
</div>
</CommandItem>
{/* The listbox wraps a scrolling pane plus the pinned Add row, so both
stay `option` children of one listbox. */}
<div
id={listId}
role="listbox"
aria-label={translate(
'auto.components.new.workspace.ProjectCombobox.listLabel',
'Projects'
)}
className="flex min-h-0 flex-col"
>
{/* Why: `presentation` this element exists to scroll, and an
unroled div between a listbox and its options breaks the
ownership relationship assistive tech relies on. */}
<div
ref={setListNode}
role="presentation"
className="max-h-72 min-h-0 flex-1 overflow-y-auto p-1 scrollbar-sleek"
>
{matches.length === 0 ? (
// Why: row-height rather than a tall centred block — a 60px panel
// next to 32px rows reads as a different kind of surface and
// makes an empty result feel like an error.
<p className="flex h-8 items-center justify-center px-2 text-sm text-muted-foreground">
{options.length === 0
? translate(
'auto.components.new.workspace.ProjectCombobox.noProjects',
'No projects yet.'
)
: translate(
'auto.components.new.workspace.ProjectCombobox.empty',
'No projects match your search.'
)}
</p>
) : null}
{sections.map((section) => (
// Why: `role="group"` — a bare div between a listbox and its
// options breaks the ownership relationship for screen readers,
// which is the only thing that makes the headings announceable.
<div key={section.key} role="group" aria-label={section.heading ?? undefined}>
{section.heading ? (
<div
aria-hidden="true"
className="px-2 pt-2.5 pb-1 text-[11px] font-semibold tracking-[0.05em] text-muted-foreground uppercase"
>
{section.heading}
</div>
) : null}
{section.items.map((scored) => (
<ProjectOptionRow
key={scored.option.id}
option={scored.option}
nameHits={scored.nameHits}
detailHits={scored.detailHits}
armed={armedKey === scored.option.id}
current={scored.option.id === value}
ambiguous={ambiguous.has(scored.option.id)}
optionId={armedKey === scored.option.id ? `${listId}-armed` : undefined}
onArm={() => arm(scored.option.id)}
onCommit={() => commit(scored.option.id)}
/>
))}
</div>
))}
</CommandList>
</div>
{onAddProject ? (
// Why: pinned below the scrollable list so "Add a new project" stays
// reachable in every state, including when the list is empty and
// CommandEmpty shows "No projects match your search."
<div className="border-t border-border">
<Button
type="button"
variant="ghost"
onClick={handleAddProject}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setCommandValue('')}
className="h-8 w-full justify-start rounded-none px-3 text-xs font-normal"
>
<FolderPlus className="size-3.5 text-muted-foreground" />
<span>
{translate(
'auto.components.new.workspace.ProjectCombobox.addProject',
'Add a new project'
)}
</span>
</Button>
<div
role="option"
id={armedKey === ADD_PROJECT_KEY ? `${listId}-armed` : undefined}
aria-selected={armedKey === ADD_PROJECT_KEY}
data-armed={armedKey === ADD_PROJECT_KEY || undefined}
onMouseDown={(event) => event.preventDefault()}
onMouseMove={() => arm(ADD_PROJECT_KEY)}
onClick={() => commit(ADD_PROJECT_KEY)}
className={cn(
'flex h-9 shrink-0 cursor-default items-center gap-2 border-t border-border px-2 text-sm',
armedKey === ADD_PROJECT_KEY && 'bg-accent text-accent-foreground'
)}
>
<FolderPlus className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">
{translate(
'auto.components.new.workspace.ProjectCombobox.addProject',
'Add a new project'
)}
</span>
</div>
) : null}
</Command>
</div>
</PopoverContent>
</Popover>
)

View File

@ -0,0 +1,146 @@
import React from 'react'
import { FolderOpen } from 'lucide-react'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import { cn } from '@/lib/utils'
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
import { splitDetailForElision } from './project-combobox-matching'
/** Identity mark shared by the field and every row, so a project reads the same in both. */
export function ProjectOptionMark({
option
}: {
option: NewWorkspaceProjectOption
}): React.JSX.Element {
return option.kind === 'project-group' ? (
<FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />
) : (
// Square, matching the mark everywhere else (jump palette, sidebar).
<RepoBadgeMark color={option.badgeColor} />
)
}
/** Underlines the matched characters so a fuzzy hit is legible, not mysterious. */
export function MatchedText({
text,
hits,
className
}: {
text: string
hits: readonly number[]
className?: string
}): React.JSX.Element {
const marks = new Set(hits)
if (marks.size === 0) {
return <span className={cn('min-w-0 truncate', className)}>{text}</span>
}
return (
<span className={cn('min-w-0 truncate', className)}>
{[...text].map((char, index) =>
marks.has(index) ? (
<mark
key={`${index}-${char}`}
className="bg-transparent p-0 font-semibold text-foreground underline decoration-ring underline-offset-2"
>
{char}
</mark>
) : (
char
)
)}
</span>
)
}
/**
* Detail line that keeps its final two path segments when space runs out
* `~/dev/…/services/checkout-api` stays distinct from its `-web` sibling, where
* a plain truncate would render both identically.
*/
export function ProjectOptionDetail({
detail,
hits,
className
}: {
detail: string
hits?: readonly number[]
className?: string
}): React.JSX.Element {
const split = splitDetailForElision(detail)
if (!split) {
return (
<span className={cn('min-w-0 truncate', className)} title={detail}>
{hits ? <MatchedText text={detail} hits={hits} /> : detail}
</span>
)
}
return (
<span className={cn('flex min-w-0 items-baseline overflow-hidden', className)} title={detail}>
{/* Head collapses first; the tail only truncates once the head is gone. */}
<span className="min-w-0 shrink-[999] truncate">{split.head}</span>
<span className="min-w-0 shrink truncate">/{split.tail}</span>
</span>
)
}
export function ProjectOptionRow({
option,
nameHits,
detailHits,
armed,
current,
ambiguous,
optionId,
onArm,
onCommit
}: {
option: NewWorkspaceProjectOption
nameHits: readonly number[]
detailHits: readonly number[]
armed: boolean
current: boolean
ambiguous: boolean
optionId: string | undefined
onArm: () => void
onCommit: () => void
}): React.JSX.Element {
return (
<div
role="option"
id={optionId}
aria-selected={armed}
data-armed={armed || undefined}
data-current={current ? 'true' : undefined}
onMouseDown={(event) => event.preventDefault()}
onMouseMove={onArm}
onClick={onCommit}
className={cn(
// Why: `items-baseline` (not `items-center`) — the name and its smaller
// detail line sit on one baseline, so the two type sizes read as one
// line rather than two boxes centred against each other.
'flex h-8 cursor-default items-baseline gap-2 rounded-sm px-2 text-sm',
armed && 'bg-accent text-accent-foreground',
current && !armed && 'bg-accent/60'
)}
>
{/* The mark is a dot, not text, so it centres on the row itself. */}
<span className="flex h-8 shrink-0 items-center">
<ProjectOptionMark option={option} />
</span>
{/* Name keeps up to half the row; the path absorbs the rest so a deep
path can't squeeze the name down to "chec…". */}
<MatchedText
text={option.displayName}
hits={nameHits}
className={cn('max-w-[50%] shrink', current && 'font-medium')}
/>
<ProjectOptionDetail
detail={option.detail}
hits={detailHits}
className={cn(
'ml-auto min-w-0 flex-1 shrink-[999] justify-end pl-2 text-right text-xs',
ambiguous ? 'text-foreground/80' : 'text-muted-foreground'
)}
/>
</div>
)
}

View File

@ -0,0 +1,380 @@
import React, { useCallback, useMemo, useState } from 'react'
import { Popover, PopoverContent } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type {
NeedsSetupProjectHostOption,
ProjectHostSetupOption
} from '@/lib/project-host-setup-options'
import { isWithinComboboxRoot, useTypeAheadCombobox } from './use-type-ahead-combobox'
import { COMBOBOX_POPOVER_SURFACE } from './type-ahead-combobox-styles'
import {
ConnectHostButton,
HostRowIcon,
NeedsSetupHostIcon,
RunTargetRow
} from './RunTargetComboboxRow'
import {
buildRunTargetRows,
getEphemeralVmLabel,
getRecipeDetail,
RUN_TARGET_ADD_HOST_KEY,
type EphemeralVmRecipeOption
} from './run-target-options'
import { AddHostSubmenuRow, RecipesSubmenuRow } from './RunTargetSubmenus'
import RunTargetField from './RunTargetField'
type RunTargetComboboxProps = {
hostOptions: readonly ProjectHostSetupOption[]
hostValue: string | null
onHostChange?: (setupId: string) => void
recipes: EphemeralVmRecipeOption[]
recipeValue: string | null
onRecipeChange?: (recipeId: string | null) => void
onAddRemoteServer?: () => void
onAddSshHost?: () => void
onConnectHost?: (option: NeedsSetupProjectHostOption) => Promise<void> | void
}
const ROOT_ATTRIBUTE = 'data-run-target-combobox-root'
/**
* Run-target picker, built to match the project picker: the field *is* the
* search, exactly one row is armed and Enter takes it, hovering arms, and the
* "Add host" row is pinned to the popover edge so it survives every state.
*
* Two things the project picker doesn't have: disconnected hosts carry an
* inline Connect action that must not select the row, and two rows open nested
* lists (VM recipes, Add host) rather than committing.
*/
export default function RunTargetCombobox({
hostOptions,
hostValue,
onHostChange,
recipes,
recipeValue,
onRecipeChange,
onAddRemoteServer,
onAddSshHost,
onConnectHost
}: RunTargetComboboxProps): React.JSX.Element {
const [submenu, setSubmenu] = useState<'recipes' | 'add-host' | null>(null)
// Track in-flight connects per host so one stalling connect never blocks the others.
const [connectingHostIds, setConnectingHostIds] = useState<ReadonlySet<string>>(() => new Set())
const hasAddHost = Boolean(onAddSshHost || onAddRemoteServer)
const deriveRowKeys = useCallback(
(query: string): string[] =>
buildRunTargetRows({ hostOptions, recipes, query, hasAddHost }).rows.map((row) => row.key),
[hasAddHost, hostOptions, recipes]
)
const combobox = useTypeAheadCombobox(deriveRowKeys)
const { query, setQuery, open, setOpen, armedKey, arm, moveArm, inputRef, listId, setListNode } =
combobox
const { rows, matchedRecipes } = useMemo(
() => buildRunTargetRows({ hostOptions, recipes, query, hasAddHost }),
[hasAddHost, hostOptions, query, recipes]
)
const readyHostOptions = useMemo(
() => hostOptions.filter((option) => option.kind === 'ready'),
[hostOptions]
)
const selectedHost =
readyHostOptions.find((option) => option.id === hostValue) ?? readyHostOptions[0] ?? null
const selectedRecipe = recipes.find((recipe) => recipe.id === recipeValue) ?? null
const armedRow = rows.find((row) => row.key === armedKey) ?? rows[0] ?? null
// Only a committed selection paints the field; typing replaces it.
const committed = query.length === 0 && (selectedRecipe !== null || selectedHost !== null)
// Closing also drops any open submenu, which the shared hook doesn't know about.
const close = useCallback((): void => {
combobox.close()
setSubmenu(null)
}, [combobox])
const selectHost = useCallback(
(setupId: string): void => {
onHostChange?.(setupId)
onRecipeChange?.(null)
close()
},
[close, onHostChange, onRecipeChange]
)
const selectRecipe = useCallback(
(recipeId: string): void => {
onRecipeChange?.(recipeId)
close()
},
[close, onRecipeChange]
)
const connectHost = useCallback(
async (option: NeedsSetupProjectHostOption): Promise<void> => {
if (!option.connectAction || !onConnectHost || connectingHostIds.has(option.hostId)) {
return
}
setConnectingHostIds((current) => new Set(current).add(option.hostId))
try {
await onConnectHost(option)
} finally {
// Always clear when the connect settles (success, failure, or timeout)
// so the row's spinner stops.
setConnectingHostIds((current) => {
if (!current.has(option.hostId)) {
return current
}
const next = new Set(current)
next.delete(option.hostId)
return next
})
}
},
[connectingHostIds, onConnectHost]
)
/** Commits a row, or opens its submenu when the row is a submenu row. */
const activate = useCallback(
(key: string | null): void => {
const row = rows.find((candidate) => candidate.key === key)
if (!row) {
return
}
if (row.kind === 'ready') {
selectHost(row.option.id)
return
}
if (row.kind === 'needs-setup') {
// Not ready: selecting is a no-op, the Connect action is the way forward.
return
}
setSubmenu(row.kind === 'recipes' ? 'recipes' : 'add-host')
},
[rows, selectHost]
)
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
setOpen(true)
moveArm(event.key === 'ArrowDown' ? 1 : -1)
setSubmenu(null)
return
}
if ((event.key === 'Enter' || event.key === 'ArrowRight') && open) {
event.preventDefault()
activate(armedRow?.key ?? null)
return
}
if (event.key === 'Escape' && (open || query.length > 0)) {
event.preventDefault()
event.stopPropagation()
// A submenu closes first, so Escape backs out one layer at a time.
if (submenu !== null) {
setSubmenu(null)
return
}
close()
}
},
[activate, armedRow, close, moveArm, open, query, setOpen, submenu]
)
const handleOpenChange = useCallback(
(next: boolean): void => {
if (next) {
setOpen(true)
return
}
close()
},
[close, setOpen]
)
const fieldLabel = selectedRecipe
? `${getEphemeralVmLabel()} / ${selectedRecipe.name}`
: (selectedHost?.label ?? '')
const fieldDetail = selectedRecipe ? getRecipeDetail(selectedRecipe) : (selectedHost?.path ?? '')
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<RunTargetField
query={query}
onQueryChange={(value) => {
setQuery(value)
setOpen(true)
setSubmenu(null)
}}
open={open}
onOpenRequest={() => setOpen(true)}
onToggle={() => setOpen(!open)}
committed={committed}
isRecipe={selectedRecipe !== null}
hostId={selectedHost?.hostId ?? null}
label={fieldLabel}
detail={fieldDetail}
listId={listId}
hasArmedRow={armedRow !== null}
inputRef={inputRef}
onKeyDown={handleKeyDown}
/>
<PopoverContent
align="start"
sideOffset={4}
className={cn(
'flex w-[var(--radix-popover-trigger-width)] min-w-[18rem] flex-col p-0',
COMBOBOX_POPOVER_SURFACE
)}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
// The field lives in the anchor, not the content, so Radix would see a
// focus/pointer event "outside" and dismiss the instant you tab in.
onFocusOutside={(event) => {
if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) {
event.preventDefault()
}
}}
onInteractOutside={(event) => {
if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) {
event.preventDefault()
}
}}
>
<div
id={listId}
role="listbox"
aria-label={translate(
'auto.components.new.workspace.RunTargetCombobox.listLabel',
'Run targets'
)}
className="flex min-h-0 flex-col"
>
<div
ref={setListNode}
role="presentation"
className="max-h-72 min-h-0 flex-1 overflow-y-auto p-1 scrollbar-sleek"
>
{rows.filter((row) => row.kind !== 'add-host').length === 0 ? (
<p className="flex h-8 items-center justify-center px-2 text-sm text-muted-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.noRunTargets',
'No run targets are ready for this project.'
)}
</p>
) : null}
{rows.map((row) => {
if (row.kind === 'add-host') {
return null
}
const isArmed = armedRow?.key === row.key
const optionId = isArmed ? `${listId}-armed` : undefined
if (row.kind === 'ready') {
return (
<RunTargetRow
key={row.key}
icon={<HostRowIcon hostId={row.option.hostId} />}
label={row.option.label}
detail={row.option.path}
armed={isArmed}
current={selectedRecipe === null && row.option.id === selectedHost?.id}
optionId={optionId}
onArm={() => {
arm(row.key)
setSubmenu(null)
}}
onCommit={() => selectHost(row.option.id)}
/>
)
}
if (row.kind === 'needs-setup') {
const connecting = connectingHostIds.has(row.option.hostId)
const hasConnect = Boolean(row.option.connectAction && onConnectHost)
return (
<RunTargetRow
key={row.key}
icon={
<NeedsSetupHostIcon
hostId={row.option.hostId}
connecting={connecting}
attention={row.option.attention}
/>
}
label={row.option.label}
// Why: a Connect button on the row already says the host
// isn't connected, so its detail line only repeats that.
// Rows without the action still need theirs to explain why.
detail={hasConnect ? '' : row.option.detail}
armed={isArmed}
current={false}
dimmed
optionId={optionId}
onArm={() => {
arm(row.key)
setSubmenu(null)
}}
onCommit={() => {}}
trailing={
row.option.connectAction && onConnectHost ? (
<ConnectHostButton
connecting={connecting}
onConnect={() => void connectHost(row.option)}
/>
) : undefined
}
/>
)
}
// Recipes submenu row.
return (
<RecipesSubmenuRow
key={row.key}
open={submenu === 'recipes'}
onOpenChange={(next) => setSubmenu(next ? 'recipes' : null)}
armed={isArmed}
optionId={optionId}
recipes={matchedRecipes}
selectedRecipeId={selectedRecipe?.id ?? null}
onArm={() => {
arm(row.key)
setSubmenu('recipes')
}}
onSelectRecipe={selectRecipe}
/>
)
})}
</div>
{hasAddHost ? (
<AddHostSubmenuRow
open={submenu === 'add-host'}
onOpenChange={(next) => setSubmenu(next ? 'add-host' : null)}
armed={armedRow?.key === RUN_TARGET_ADD_HOST_KEY}
optionId={armedRow?.key === RUN_TARGET_ADD_HOST_KEY ? `${listId}-armed` : undefined}
onArm={() => {
arm(RUN_TARGET_ADD_HOST_KEY)
setSubmenu('add-host')
}}
{...(onAddSshHost
? {
onAddSshHost: () => {
close()
onAddSshHost()
}
}
: {})}
{...(onAddRemoteServer
? {
onAddRemoteServer: () => {
close()
onAddRemoteServer()
}
}
: {})}
/>
) : null}
</div>
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,175 @@
import React from 'react'
import { AlertTriangle, ChevronRight, LoaderCircle, Monitor, Server } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host'
import { ProjectOptionDetail } from './ProjectComboboxRow'
import { translate } from '@/i18n/i18n'
/** The local machine isn't a server — a monitor glyph reads as "this computer". */
export function HostRowIcon({ hostId }: { hostId: ExecutionHostId }): React.JSX.Element {
const Icon = hostId === LOCAL_EXECUTION_HOST_ID ? Monitor : Server
return <Icon className="size-3.5 shrink-0 text-muted-foreground" />
}
/**
* One run-target row. Shares the Project picker's shape 32px, label and
* right-aligned detail on one baseline so the two composer fields read as one
* control. `stacked` switches to a two-line card for the Add-host choices,
* where the description explains what you're picking rather than labelling a
* thing you already know.
*/
export function RunTargetRow({
icon,
label,
detail,
armed,
current,
optionId,
dimmed = false,
submenu = false,
stacked = false,
onArm,
onCommit,
trailing
}: {
icon: React.ReactNode
label: string
detail: string
armed: boolean
current: boolean
optionId: string | undefined
/** Not-ready hosts are dormant, not errors — quiet them without disabling. */
dimmed?: boolean
/** Opens a nested list, so it gets a trailing chevron. */
submenu?: boolean
/** Two-line card: label over description, for rows that need explaining. */
stacked?: boolean
onArm: () => void
onCommit: () => void
trailing?: React.ReactNode
}): React.JSX.Element {
return (
<div
role="option"
id={optionId}
aria-selected={armed}
// `option` supports aria-haspopup but not aria-expanded, so the row
// announces that it opens a menu without claiming an invalid state.
aria-haspopup={submenu ? 'menu' : undefined}
data-armed={armed || undefined}
data-current={current ? 'true' : undefined}
onMouseDown={(event) => event.preventDefault()}
onMouseMove={onArm}
onClick={onCommit}
className={cn(
'flex cursor-default gap-2 rounded-sm px-2 text-sm',
stacked ? 'items-center py-1.5' : 'h-8 items-baseline',
armed && 'bg-accent text-accent-foreground',
current && !armed && 'bg-accent/60'
)}
>
{/* Icons are glyphs, not text, so they centre on the row. */}
<span
className={cn(
'flex shrink-0 items-center',
stacked ? 'self-start pt-0.5' : 'h-8',
dimmed && 'opacity-60'
)}
>
{icon}
</span>
{stacked ? (
<span className="flex min-w-0 flex-1 flex-col">
<span className={cn('truncate', current && 'font-medium')}>{label}</span>
<span className="truncate text-xs text-muted-foreground">{detail}</span>
</span>
) : (
<>
<span
className={cn(
'max-w-[50%] shrink truncate',
current && 'font-medium',
dimmed && 'opacity-60'
)}
>
{label}
</span>
<ProjectOptionDetail
detail={detail}
className={cn(
'ml-auto min-w-0 flex-1 shrink-[999] justify-end pl-2 text-right text-xs text-muted-foreground',
dimmed && 'opacity-60'
)}
/>
</>
)}
{trailing}
{submenu ? (
<span className={cn('flex shrink-0 items-center pl-1.5', stacked ? 'self-center' : 'h-8')}>
<ChevronRight className="size-3.5 text-muted-foreground" />
</span>
) : null}
</div>
)
}
/** Glyph for a host that can't run yet: connecting, broken, or merely dormant. */
export function NeedsSetupHostIcon({
hostId,
connecting,
attention
}: {
hostId: ExecutionHostId
connecting: boolean
attention: boolean
}): React.JSX.Element {
if (connecting) {
return <LoaderCircle className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
}
if (attention) {
return <AlertTriangle className="size-3.5 shrink-0 text-muted-foreground" />
}
return <HostRowIcon hostId={hostId} />
}
/** Inline Connect action on a disconnected host row. */
export function ConnectHostButton({
connecting,
onConnect
}: {
connecting: boolean
onConnect: () => void
}): React.JSX.Element {
return (
<Button
type="button"
variant="ghost"
size="xs"
// Why: only the row being connected is disabled, so one slow connect
// can't lock out connecting to the others.
disabled={connecting}
className="ml-1 shrink-0 gap-1 self-center text-muted-foreground/70 hover:text-foreground"
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
// Why: keep the picker open so the connecting state stays visible; the
// row updates in place once store SSH state resolves.
onConnect()
}}
>
{connecting ? (
<>
<LoaderCircle className="size-3 animate-spin" />
{translate('auto.components.NewWorkspaceComposerCard.connectingHost', 'Connecting…')}
</>
) : (
translate('auto.components.NewWorkspaceComposerCard.connectHost', 'Connect')
)}
</Button>
)
}

View File

@ -0,0 +1,134 @@
import React from 'react'
import { ChevronDown, Cloud } from 'lucide-react'
import { PopoverAnchor } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { HostRowIcon } from './RunTargetComboboxRow'
import { COMBOBOX_FIELD_SHELL } from './type-ahead-combobox-styles'
/**
* The run-target field: a text input that *is* the search box, painted over
* with the committed selection so a long label and its path shrink at
* different rates instead of truncating as one flat string.
*/
export default function RunTargetField({
query,
onQueryChange,
open,
onOpenRequest,
onToggle,
committed,
isRecipe,
hostId,
label,
detail,
listId,
hasArmedRow,
inputRef,
onKeyDown
}: {
query: string
onQueryChange: (value: string) => void
open: boolean
onOpenRequest: () => void
onToggle: () => void
committed: boolean
isRecipe: boolean
hostId: ExecutionHostId | null
label: string
detail: string
listId: string
hasArmedRow: boolean
inputRef: React.RefObject<HTMLInputElement | null>
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
}): React.JSX.Element {
return (
<PopoverAnchor asChild>
<div
data-run-target-combobox-root="true"
onClick={() => {
inputRef.current?.focus()
onOpenRequest()
}}
className={COMBOBOX_FIELD_SHELL}
>
<span className="flex w-4 shrink-0 items-center justify-center">
{committed ? (
isRecipe ? (
<Cloud className="size-3.5 shrink-0 text-muted-foreground" />
) : hostId ? (
<HostRowIcon hostId={hostId} />
) : null
) : null}
</span>
<div className="relative min-w-0 flex-1 overflow-hidden">
<input
ref={inputRef}
type="text"
role="combobox"
data-run-target-combobox-root="true"
aria-label={translate(
'auto.components.new.workspace.RunTargetCombobox.label',
'Run on'
)}
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={open && hasArmedRow ? `${listId}-armed` : undefined}
value={query}
placeholder={
committed
? ''
: translate(
'auto.components.NewWorkspaceComposerCard.chooseRunTarget',
'Choose target'
)
}
onChange={(event) => onQueryChange(event.target.value)}
onFocus={onOpenRequest}
onKeyDown={onKeyDown}
className={cn(
'w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground',
committed && 'text-transparent caret-foreground'
)}
/>
{committed ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center text-sm"
>
{/* Baseline-aligned: centring two type sizes leaves the smaller high. */}
<div className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 max-w-[50%] shrink truncate">{label}</span>
<span
className="min-w-0 flex-1 shrink-[999] truncate text-right text-xs text-muted-foreground"
title={detail}
>
{detail}
</span>
</div>
</div>
) : null}
</div>
<button
type="button"
tabIndex={-1}
aria-label={translate(
'auto.components.new.workspace.RunTargetCombobox.browse',
'Browse run targets'
)}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
inputRef.current?.focus()
onToggle()
}}
className="-mr-1 flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ChevronDown className={cn('size-3.5 transition-transform', open && 'rotate-180')} />
</button>
</div>
</PopoverAnchor>
)
}

View File

@ -0,0 +1,192 @@
import React from 'react'
import { ChevronDown, Cloud, Plus, Server } from 'lucide-react'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { RunTargetRow } from './RunTargetComboboxRow'
import {
getEphemeralVmLabel,
getRecipeDetail,
RUN_TARGET_ADD_HOST_KEY,
type EphemeralVmRecipeOption
} from './run-target-options'
import { COMBOBOX_POPOVER_SURFACE } from './type-ahead-combobox-styles'
const SUBMENU_CONTENT = cn('w-72 p-1', COMBOBOX_POPOVER_SURFACE)
/** The "Per-Workspace Environment" row and its nested recipe list. */
export function RecipesSubmenuRow({
open,
onOpenChange,
armed,
optionId,
recipes,
selectedRecipeId,
onArm,
onSelectRecipe
}: {
open: boolean
onOpenChange: (open: boolean) => void
armed: boolean
optionId: string | undefined
recipes: readonly EphemeralVmRecipeOption[]
selectedRecipeId: string | null
onArm: () => void
onSelectRecipe: (recipeId: string) => void
}): React.JSX.Element {
const [hoveredKey, setHoveredKey] = React.useState<string | null>(null)
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverAnchor asChild>
<div>
<RunTargetRow
icon={<Cloud className="size-3.5 shrink-0 text-muted-foreground" />}
label={getEphemeralVmLabel()}
detail={translate(
'auto.components.NewWorkspaceComposerCard.perWorkspaceEnvHint',
'Provision an on-demand environment from a recipe'
)}
armed={armed}
current={selectedRecipeId !== null}
optionId={optionId}
submenu
onArm={onArm}
onCommit={() => onOpenChange(true)}
/>
</div>
</PopoverAnchor>
<PopoverContent
side="right"
align="start"
sideOffset={6}
className={SUBMENU_CONTENT}
onOpenAutoFocus={(event) => event.preventDefault()}
>
{/* Why: submenu rows track their own hover without it they were the
only rows in either picker that never highlighted under the pointer. */}
<div
role="listbox"
aria-label={getEphemeralVmLabel()}
onMouseLeave={() => setHoveredKey(null)}
>
{recipes.map((recipe) => (
<RunTargetRow
key={recipe.id}
icon={<Cloud className="size-3.5 shrink-0 text-muted-foreground" />}
label={recipe.name}
detail={getRecipeDetail(recipe)}
armed={hoveredKey === recipe.id}
current={recipe.id === selectedRecipeId}
optionId={undefined}
onArm={() => setHoveredKey(recipe.id)}
onCommit={() => onSelectRecipe(recipe.id)}
/>
))}
</div>
</PopoverContent>
</Popover>
)
}
/**
* "Add host", pinned to the popover edge so it stays reachable in every state
* scrolled, filtered to nothing, or with no hosts at all.
*/
export function AddHostSubmenuRow({
open,
onOpenChange,
armed,
optionId,
onArm,
onAddSshHost,
onAddRemoteServer
}: {
open: boolean
onOpenChange: (open: boolean) => void
armed: boolean
optionId: string | undefined
onArm: () => void
onAddSshHost?: () => void
onAddRemoteServer?: () => void
}): React.JSX.Element {
const [hoveredKey, setHoveredKey] = React.useState<string | null>(null)
const addHostLabel = translate('auto.components.NewWorkspaceComposerCard.addHost', 'Add host')
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverAnchor asChild>
<div
role="option"
id={optionId}
aria-selected={armed}
// `option` supports aria-haspopup but not aria-expanded.
aria-haspopup="menu"
data-armed={armed || undefined}
data-run-target-add-host="true"
onMouseDown={(event) => event.preventDefault()}
onMouseMove={onArm}
onClick={() => onOpenChange(true)}
className={cn(
'flex h-9 shrink-0 cursor-default items-center gap-2 border-t border-border px-2 text-sm',
armed && 'bg-accent text-accent-foreground'
)}
>
<Plus className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{addHostLabel}</span>
<span className="ml-auto flex shrink-0 items-center">
<ChevronDown className="size-3.5 -rotate-90 text-muted-foreground" />
</span>
</div>
</PopoverAnchor>
<PopoverContent
side="right"
align="end"
sideOffset={6}
className={SUBMENU_CONTENT}
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div role="listbox" aria-label={addHostLabel} onMouseLeave={() => setHoveredKey(null)}>
{onAddSshHost ? (
<RunTargetRow
icon={<Server className="size-3.5 shrink-0 text-muted-foreground" />}
label={translate(
'auto.components.NewWorkspaceComposerCard.addSshHost',
'Add SSH host'
)}
detail={translate(
'auto.components.NewWorkspaceComposerCard.addSshHostHint',
'Use an existing machine over SSH'
)}
armed={hoveredKey === 'ssh'}
current={false}
stacked
optionId={undefined}
onArm={() => setHoveredKey('ssh')}
onCommit={onAddSshHost}
/>
) : null}
{onAddRemoteServer ? (
<RunTargetRow
icon={<Cloud className="size-3.5 shrink-0 text-muted-foreground" />}
label={translate(
'auto.components.NewWorkspaceComposerCard.addRemoteOrcaServer',
'Add Remote Orca Server'
)}
detail={translate(
'auto.components.NewWorkspaceComposerCard.addRemoteOrcaServerHint',
'Pair another Orca runtime'
)}
armed={hoveredKey === 'remote'}
current={false}
stacked
optionId={undefined}
onArm={() => setHoveredKey('remote')}
onCommit={onAddRemoteServer}
/>
) : null}
</div>
</PopoverContent>
</Popover>
)
}
export { RUN_TARGET_ADD_HOST_KEY }

View File

@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
import {
getAmbiguousProjectOptionIds,
rankProjectOptions,
sectionProjectOptions,
splitDetailForElision
} from './project-combobox-matching'
function project(id: string, displayName: string, detail: string): NewWorkspaceProjectOption {
return { kind: 'project', id, projectId: id, displayName, badgeColor: '#111', detail }
}
const orca = project('orca', 'orca', 'stablyai/orca')
const relay = project('relay', 'orca-relay', 'stablyai/orca-relay')
const gateway = project('gateway', 'api-gateway', 'acme/api-gateway')
describe('rankProjectOptions', () => {
it('ranks a name-prefix match above a mid-name match', () => {
const ranked = rankProjectOptions([relay, orca], 'orca', [])
expect(ranked[0]?.option.id).toBe('orca')
})
it('matches the detail line when the name does not match', () => {
const ranked = rankProjectOptions([gateway], 'acme', [])
expect(ranked).toHaveLength(1)
expect(ranked[0]?.detailHits.length).toBeGreaterThan(0)
})
it('matches a scattered subsequence in the name but not in the detail', () => {
const hosts = project('infra', 'infra', '3 hosts configured')
// "sc" appears scattered in "hosts configured" but detail matching is
// substring-only, so this must not match on the detail line.
expect(rankProjectOptions([hosts], 'sc', [])).toHaveLength(0)
expect(rankProjectOptions([hosts], 'ifa', [])).toHaveLength(1)
})
it('orders an unfiltered list by recency', () => {
const ranked = rankProjectOptions([orca, relay, gateway], '', ['gateway', 'relay'])
expect(ranked.map((r) => r.option.id)).toEqual(['gateway', 'relay', 'orca'])
})
it('returns nothing for an oversized query rather than scanning it', () => {
expect(rankProjectOptions([orca], 'x'.repeat(4096), [])).toEqual([])
})
})
describe('sectionProjectOptions', () => {
const many = Array.from({ length: 7 }, (_, i) => project(`p${i}`, `proj-${i}`, `acme/proj-${i}`))
it('collapses to a single unlabelled list while a query is live', () => {
const matches = rankProjectOptions(many, 'proj', [])
const sections = sectionProjectOptions(matches, 'proj', ['p3'])
expect(sections).toHaveLength(1)
expect(sections[0]?.heading).toBeNull()
})
it('splits into Recent/Projects once the list is long enough to warrant it', () => {
const matches = rankProjectOptions(many, '', ['p3', 'p5'])
const sections = sectionProjectOptions(matches, '', ['p3', 'p5'])
expect(sections.map((s) => s.heading)).toEqual(['Recent', 'Projects'])
expect(sections[0]?.items.map((i) => i.option.id)).toEqual(['p3', 'p5'])
})
it('keeps a short list unsectioned', () => {
const matches = rankProjectOptions([orca, relay], '', ['relay'])
expect(sectionProjectOptions(matches, '', ['relay'])).toHaveLength(1)
})
})
describe('getAmbiguousProjectOptionIds', () => {
it('flags only ids whose display name repeats', () => {
const a = project('a', 'scratch', '~/code/scratch')
const b = project('b', 'scratch', '~/src/scratch')
const ids = getAmbiguousProjectOptionIds([a, b, orca])
expect(ids).toEqual(new Set(['a', 'b']))
})
})
describe('splitDetailForElision', () => {
it('keeps the last two segments so sibling paths stay distinguishable', () => {
const split = splitDetailForElision('~/Developer/work/acme/monorepo/services/checkout-api')
expect(split?.tail).toBe('services/checkout-api')
})
it('leaves short or shallow details alone', () => {
expect(splitDetailForElision('stablyai/orca')).toBeNull()
expect(splitDetailForElision('3 hosts configured')).toBeNull()
})
})

View File

@ -0,0 +1,156 @@
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
import { isNewWorkspaceProjectOptionQueryTooLarge } from '@/lib/new-workspace-project-options'
export type ScoredProjectOption = {
option: NewWorkspaceProjectOption
score: number
nameHits: readonly number[]
detailHits: readonly number[]
}
function substringHits(text: string, query: string): number[] | null {
const at = text.toLowerCase().indexOf(query)
return at < 0 ? null : Array.from({ length: query.length }, (_, offset) => at + offset)
}
/**
* Verbatim run, else scattered subsequence. Only names get the loose pass
* a subsequence over a detail line matches "scr" against "3 hosts configured".
*/
function nameHitsFor(name: string, query: string): number[] | null {
const verbatim = substringHits(name, query)
if (verbatim) {
return verbatim
}
const haystack = name.toLowerCase()
const hits: number[] = []
let cursor = 0
for (const char of query) {
const found = haystack.indexOf(char, cursor)
if (found < 0) {
return null
}
hits.push(found)
cursor = found + 1
}
return hits
}
function nameScore(name: string, hits: readonly number[]): number {
const first = hits[0] ?? 0
const contiguous = (hits.at(-1) ?? first) - first === hits.length - 1
const boundary = first === 0 || /[^a-z0-9]/i.test(name[first - 1] ?? '')
const base = contiguous ? (first === 0 ? 900 : boundary ? 780 : 700) : 420
return base - name.length * 0.4
}
/**
* Ranks options for a query, with recency breaking ties (and ordering the
* unfiltered list). `recentIds` is most-recent-first.
*/
export function rankProjectOptions(
options: readonly NewWorkspaceProjectOption[],
rawQuery: string,
recentIds: readonly string[]
): ScoredProjectOption[] {
if (isNewWorkspaceProjectOptionQueryTooLarge(rawQuery)) {
return []
}
const query = rawQuery.trim().toLowerCase()
const scored: ScoredProjectOption[] = []
for (const option of options) {
const recentAt = recentIds.indexOf(option.id)
const recency = recentAt < 0 ? 0 : 32 - recentAt * 4
if (query.length === 0) {
scored.push({ option, score: recency, nameHits: [], detailHits: [] })
continue
}
const nameHits = nameHitsFor(option.displayName, query)
const detailHits = substringHits(option.detail, query)
if (!nameHits && !detailHits) {
continue
}
scored.push({
option,
score: (nameHits ? nameScore(option.displayName, nameHits) : 260) + recency,
nameHits: nameHits ?? [],
detailHits: detailHits ?? []
})
}
return scored.sort(
(a, b) =>
b.score - a.score ||
a.option.displayName.localeCompare(b.option.displayName) ||
a.option.detail.localeCompare(b.option.detail)
)
}
export type ProjectOptionSection = {
key: 'recent' | 'projects' | 'folders' | 'results'
heading: string | null
items: ScoredProjectOption[]
}
/** Below this a list is scannable, so sections are chrome rather than help. */
const SECTION_THRESHOLD = 6
const RECENT_LIMIT = 4
/**
* While a query is live the ranking *is* the order, so sections would fight it:
* everything collapses into one unlabelled result list.
*/
export function sectionProjectOptions(
matches: readonly ScoredProjectOption[],
query: string,
recentIds: readonly string[]
): ProjectOptionSection[] {
if (query.trim() !== '' || matches.length < SECTION_THRESHOLD) {
return [{ key: 'results', heading: null, items: [...matches] }]
}
const recentSet = new Set(
recentIds.filter((id) => matches.some((m) => m.option.id === id)).slice(0, RECENT_LIMIT)
)
const recent = recentIds.flatMap((id) =>
recentSet.has(id) ? matches.filter((m) => m.option.id === id) : []
)
const sections: ProjectOptionSection[] = [
{ key: 'recent', heading: 'Recent', items: recent },
{
key: 'projects',
heading: 'Projects',
items: matches.filter((m) => m.option.kind === 'project' && !recentSet.has(m.option.id))
},
{
key: 'folders',
heading: 'Folders',
items: matches.filter((m) => m.option.kind === 'project-group')
}
]
return sections.filter((section) => section.items.length > 0)
}
/** Ids whose displayName repeats — those rows can't be read by name alone. */
export function getAmbiguousProjectOptionIds(
options: readonly NewWorkspaceProjectOption[]
): Set<string> {
const counts = new Map<string, number>()
for (const option of options) {
counts.set(option.displayName, (counts.get(option.displayName) ?? 0) + 1)
}
return new Set(
options.filter((o) => (counts.get(o.displayName) ?? 0) > 1).map((option) => option.id)
)
}
/**
* A deep path's identity lives in its tail (`…/services/checkout-api`), which is
* exactly what a plain truncate throws away two sibling paths then render
* identically. Split so the head can elide while the tail keeps its width.
*/
export function splitDetailForElision(detail: string): { head: string; tail: string } | null {
const segments = detail.split('/')
if (segments.length <= 3 || detail.length <= 28) {
return null
}
return { head: segments.slice(0, -2).join('/'), tail: segments.slice(-2).join('/') }
}

View File

@ -0,0 +1,110 @@
import type { OrcaHooks } from '../../../../shared/types'
import type {
NeedsSetupProjectHostOption,
ProjectHostSetupOption,
ReadyProjectHostSetupOption
} from '@/lib/project-host-setup-options'
import { translate } from '@/i18n/i18n'
export type EphemeralVmRecipeOption = NonNullable<OrcaHooks['environmentRecipes']>[number]
export const RUN_TARGET_ADD_HOST_KEY = 'add-host'
export const RUN_TARGET_RECIPES_KEY = 'per-workspace-env'
/** A row in the run-target list. Hosts commit; the last two open a submenu. */
export type RunTargetRowModel =
| { key: string; kind: 'ready'; option: ReadyProjectHostSetupOption }
| { key: string; kind: 'needs-setup'; option: NeedsSetupProjectHostOption }
| { key: typeof RUN_TARGET_RECIPES_KEY; kind: 'recipes' }
| { key: typeof RUN_TARGET_ADD_HOST_KEY; kind: 'add-host' }
export function getEphemeralVmLabel(): string {
return translate(
'auto.components.NewWorkspaceComposerCard.ephemeralVm',
'Per-Workspace Environment'
)
}
export function getRecipeCommandDisplay(command: string): string {
const trimmed = command.trim()
const quoted = trimmed.match(/^"([^"]+)"/) ?? trimmed.match(/^'([^']+)'/)
return quoted?.[1] ?? trimmed.split(/\s+/)[0] ?? trimmed
}
export function getRecipeDestroyLabel(recipe: EphemeralVmRecipeOption): string {
if (recipe.destroyDisabled) {
return translate('auto.components.NewWorkspaceComposerCard.destroyDisabled', 'destroy disabled')
}
if (recipe.destroy) {
return translate(
'auto.components.NewWorkspaceComposerCard.destroyConfigured',
'destroy configured'
)
}
return translate('auto.components.NewWorkspaceComposerCard.noDestroyConfigured', 'no destroy')
}
/** One line under a recipe name: what it runs and whether it tears down. */
export function getRecipeDetail(recipe: EphemeralVmRecipeOption): string {
return `${getRecipeCommandDisplay(recipe.create)} · ${getRecipeDestroyLabel(recipe)}`
}
function matches(haystack: string, query: string): boolean {
return haystack.toLowerCase().includes(query)
}
/**
* Filters hosts and recipes by a typed query. The submenu rows survive filtering
* only while they still have something to show "Add host" always survives, so
* it stays reachable in every state including no-matches.
*/
export function buildRunTargetRows({
hostOptions,
recipes,
query,
hasAddHost
}: {
hostOptions: readonly ProjectHostSetupOption[]
recipes: readonly EphemeralVmRecipeOption[]
query: string
hasAddHost: boolean
}): { rows: RunTargetRowModel[]; matchedRecipes: EphemeralVmRecipeOption[] } {
const trimmed = query.trim().toLowerCase()
const hostMatches = (option: ProjectHostSetupOption): boolean =>
trimmed === '' ||
matches(option.label, trimmed) ||
matches(option.detail, trimmed) ||
(option.kind === 'ready' && matches(option.path, trimmed))
const ready = hostOptions.filter(
(option): option is ReadyProjectHostSetupOption =>
option.kind === 'ready' && hostMatches(option)
)
const needsSetup = hostOptions.filter(
(option): option is NeedsSetupProjectHostOption =>
option.kind === 'needs-setup' && hostMatches(option)
)
const matchedRecipes = recipes.filter(
(recipe) =>
trimmed === '' ||
matches(recipe.name, trimmed) ||
matches(getEphemeralVmLabel(), trimmed) ||
matches(recipe.description ?? '', trimmed)
)
const rows: RunTargetRowModel[] = [
...ready.map((option) => ({ key: `host:${option.id}`, kind: 'ready' as const, option })),
...needsSetup.map((option) => ({
key: `needs:${option.id}`,
kind: 'needs-setup' as const,
option
}))
]
if (matchedRecipes.length > 0) {
rows.push({ key: RUN_TARGET_RECIPES_KEY, kind: 'recipes' })
}
if (hasAddHost) {
rows.push({ key: RUN_TARGET_ADD_HOST_KEY, kind: 'add-host' })
}
return { rows, matchedRecipes }
}

View File

@ -0,0 +1,16 @@
/**
* Shared recipes for the composer's type-ahead pickers, so Project and Run on
* can't drift apart.
*/
/** Input-shaped shell without being an `<Input>` — the field wraps a bare input. */
export const COMBOBOX_FIELD_SHELL =
'flex h-9 w-full min-w-0 items-center gap-2 rounded-md border border-input bg-transparent px-2.5 shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30'
/**
* Opaque and unfaded, unlike the shared popover recipe. These land directly on
* the composer dialog, so a translucent fade shows the form underneath
* mid-animation and the open reads as a double flash.
*/
export const COMBOBOX_POPOVER_SURFACE =
'bg-[var(--popover)] data-[state=closed]:fade-out-100 data-[state=open]:fade-in-100 dark:bg-[var(--popover)]'

View File

@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import type { Worktree } from '../../../../shared/types'
import { orderProjectIdsByRecency } from './use-recent-project-ids'
function worktree(partial: Partial<Worktree>): Worktree {
return {
id: `${partial.repoId ?? 'repo'}::${partial.path ?? '/tmp/wt'}`,
repoId: partial.repoId ?? 'repo',
displayName: 'wt',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
path: '/tmp/wt',
branch: 'main',
...partial
} as Worktree
}
describe('orderProjectIdsByRecency', () => {
it('orders projects by their newest workspace, newest first', () => {
const ids = orderProjectIdsByRecency([
worktree({ projectId: 'alpha', createdAt: 10 }),
worktree({ projectId: 'beta', createdAt: 30 }),
worktree({ projectId: 'gamma', createdAt: 20 })
])
expect(ids.filter((id) => !id.includes(':'))).toEqual(['beta', 'gamma', 'alpha'])
})
it('uses the newest workspace per project, not the oldest', () => {
const ids = orderProjectIdsByRecency([
worktree({ projectId: 'alpha', createdAt: 1 }),
worktree({ projectId: 'alpha', createdAt: 99 }),
worktree({ projectId: 'beta', createdAt: 50 })
])
expect(ids[0]).toBe('alpha')
})
it('skips legacy repo-only workspaces that carry no project identity', () => {
const ids = orderProjectIdsByRecency([
worktree({ projectId: undefined, createdAt: 99 }),
worktree({ projectId: 'beta', createdAt: 1 })
])
expect(ids).toContain('beta')
expect(ids).not.toContain(undefined)
})
it('emits the folder-group option id too, so grouped targets resolve', () => {
const ids = orderProjectIdsByRecency([worktree({ projectId: 'alpha', createdAt: 5 })])
expect(ids).toContain('project-group:alpha')
})
it('treats a missing createdAt as oldest rather than crashing', () => {
const ids = orderProjectIdsByRecency([
worktree({ projectId: 'alpha' }),
worktree({ projectId: 'beta', createdAt: 5 })
])
expect(ids[0]).toBe('beta')
})
})

View File

@ -0,0 +1,38 @@
import { useMemo } from 'react'
import type { Worktree } from '../../../../shared/types'
import { useAllWorktrees } from '@/store/selectors'
import { NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX } from '@/lib/new-workspace-project-options'
/**
* Most-recently-used project option ids, newest first, derived from when the
* user last created a workspace in each project. Orca stores no explicit
* per-project recency, and workspace creation is exactly the action this picker
* is about to repeat, so it's the honest proxy rather than a new store field.
*/
export function orderProjectIdsByRecency(worktrees: readonly Worktree[]): string[] {
const newestByProject = new Map<string, number>()
for (const worktree of worktrees) {
const projectId = worktree.projectId
// Legacy repo-only workspaces have no projectId and can't be attributed.
if (projectId === undefined || projectId === '') {
continue
}
const createdAt = worktree.createdAt ?? 0
const seen = newestByProject.get(projectId)
if (seen === undefined || createdAt > seen) {
newestByProject.set(projectId, createdAt)
}
}
return [...newestByProject.entries()]
.sort((a, b) => b[1] - a[1])
.flatMap(([projectId]) => [
projectId,
// Folder-group options carry a prefixed id, so both forms resolve.
`${NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX}${projectId}`
])
}
export function useRecentProjectIds(): string[] {
const worktrees = useAllWorktrees()
return useMemo(() => orderProjectIdsByRecency(worktrees), [worktrees])
}

View File

@ -0,0 +1,113 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { useWheelScrollable } from './use-wheel-scrollable'
/**
* The machinery shared by the composer's type-ahead pickers (Project, Run on):
* the field is the search box, exactly one row is armed at a time, and Enter
* takes it.
*
* Two subtleties worth keeping:
* - Armed is tracked by row *key*, not index, so a list arriving late over SSH
* cannot slide a different row under a keypress the user already aimed. The
* query it was aimed at rides along, so a newer query drops a stale arm
* during render rather than needing a reset effect.
* - Closing without committing clears the query, or the field is left showing
* text that matches nothing while hiding the committed selection.
*/
export function useTypeAheadCombobox(deriveRowKeys: (query: string) => readonly string[]): {
query: string
setQuery: (value: string) => void
open: boolean
setOpen: (open: boolean) => void
/** Clears the query and closes — the "back out without committing" path. */
close: () => void
/** Radix `onOpenChange`: closing always drops an uncommitted query. */
handleOpenChange: (next: boolean) => void
/** Row keys for the current query, as returned by `deriveRowKeys`. */
rowKeys: readonly string[]
armedKey: string | null
arm: (key: string) => void
/** Moves the arm by `step` rows, clamped to the ends. */
moveArm: (step: number) => void
inputRef: React.RefObject<HTMLInputElement | null>
listId: string
/** Ref callback for the scroll pane; also restores wheel scrolling. */
setListNode: (node: HTMLDivElement | null) => void
} {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const [armed, setArmed] = useState<{ key: string; query: string } | null>(null)
const rowKeys = deriveRowKeys(query)
const inputRef = useRef<HTMLInputElement>(null)
// react-remove-scroll (Radix Dialog) cancels wheel events for this portaled
// pane, so the list has to scroll itself.
const { ref: listRef, setNode: setListNode } = useWheelScrollable<HTMLDivElement>()
const listId = React.useId()
// A stale arm (aimed at an earlier query) falls back to the first row.
const armedKey = armed !== null && armed.query === query ? armed.key : null
const armedIndex = Math.max(armedKey === null ? -1 : rowKeys.indexOf(armedKey), 0)
const resolvedKey = rowKeys[armedIndex] ?? null
React.useEffect(() => {
if (open) {
listRef.current?.querySelector('[data-armed="true"]')?.scrollIntoView({ block: 'nearest' })
}
}, [listRef, open, armedIndex, rowKeys.length])
const arm = useCallback(
// `query` is read at call time; the closure refreshes each render.
(key: string) => setArmed({ key, query }),
[query]
)
const moveArm = useCallback(
(step: number): void => {
const next = rowKeys[Math.min(Math.max(armedIndex + step, 0), rowKeys.length - 1)]
if (next !== undefined) {
setArmed({ key: next, query })
}
},
[armedIndex, query, rowKeys]
)
const close = useCallback((): void => {
setOpen(false)
setQuery('')
}, [])
const handleOpenChange = useCallback(
(next: boolean): void => {
if (next) {
setOpen(true)
return
}
close()
},
[close]
)
return useMemo(
() => ({
query,
setQuery,
open,
setOpen,
close,
handleOpenChange,
rowKeys,
armedKey: resolvedKey,
arm,
moveArm,
inputRef,
listId,
setListNode
}),
[arm, close, handleOpenChange, listId, moveArm, open, query, resolvedKey, rowKeys, setListNode]
)
}
/** True when an event target lies inside the control marked by `rootAttribute`. */
export function isWithinComboboxRoot(target: EventTarget | null, rootAttribute: string): boolean {
return target instanceof Element && target.closest(`[${rootAttribute}="true"]`) !== null
}

View File

@ -0,0 +1,54 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
/**
* Restores mouse-wheel scrolling for a scroll container inside a portaled
* popover, and hands back the node.
*
* Radix Dialog applies react-remove-scroll, which calls preventDefault() on
* wheel events for elements outside the dialog's DOM tree the scrollbar
* renders and drags fine, but the wheel does nothing. A non-passive listener on
* the container scrolls it manually instead. `ui/command`'s CommandList carries
* the same shim; this is the equivalent for a plain scroll pane.
*/
export function useWheelScrollable<T extends HTMLElement>(): {
ref: MutableRefObject<T | null>
setNode: (node: T | null) => void
} {
const ref = useRef<T | null>(null)
const detachRef = useRef<(() => void) | null>(null)
const setNode = useCallback((node: T | null): void => {
detachRef.current?.()
detachRef.current = null
ref.current = node
if (!node) {
return
}
const onWheel = (event: WheelEvent): void => {
if (node.scrollHeight <= node.clientHeight) {
return
}
const delta =
event.deltaMode === WheelEvent.DOM_DELTA_LINE
? event.deltaY * 16
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
? event.deltaY * node.clientHeight
: event.deltaY
const max = node.scrollHeight - node.clientHeight
const next = Math.max(0, Math.min(max, node.scrollTop + delta))
if (next === node.scrollTop) {
// At an edge: let the event bubble so an ancestor can take the scroll.
return
}
event.preventDefault()
event.stopPropagation()
node.scrollTop = next
}
node.addEventListener('wheel', onWheel, { passive: false })
detachRef.current = () => node.removeEventListener('wheel', onWheel)
}, [])
useEffect(() => () => detachRef.current?.(), [])
return { ref, setNode }
}

View File

@ -11411,9 +11411,17 @@
"placeholder": "Choose host"
},
"ProjectCombobox": {
"search": "Search projects...",
"empty": "No projects match your search.",
"addProject": "Add a new project"
"addProject": "Add a new project",
"label": "Project",
"browse": "Browse projects",
"listLabel": "Projects",
"noProjects": "No projects yet."
},
"RunTargetCombobox": {
"listLabel": "Run targets",
"label": "Run on",
"browse": "Browse run targets"
}
}
},

View File

@ -11388,9 +11388,17 @@
"placeholder": "Elegir host"
},
"ProjectCombobox": {
"search": "Buscar proyectos...",
"empty": "Ningún proyecto coincide con tu búsqueda.",
"addProject": "Add a new project"
"addProject": "Add a new project",
"label": "Project",
"browse": "Browse projects",
"listLabel": "Projects",
"noProjects": "No projects yet."
},
"RunTargetCombobox": {
"listLabel": "Run targets",
"label": "Run on",
"browse": "Browse run targets"
}
}
},

View File

@ -11388,9 +11388,17 @@
"placeholder": "Choose host"
},
"ProjectCombobox": {
"search": "Search projects...",
"empty": "No projects match your search.",
"addProject": "Add a new project"
"addProject": "Add a new project",
"label": "Project",
"browse": "Browse projects",
"listLabel": "Projects",
"noProjects": "No projects yet."
},
"RunTargetCombobox": {
"listLabel": "Run targets",
"label": "Run on",
"browse": "Browse run targets"
}
}
},

View File

@ -11388,9 +11388,17 @@
"placeholder": "호스트 선택"
},
"ProjectCombobox": {
"search": "프로젝트 검색...",
"empty": "검색과 일치하는 프로젝트가 없습니다.",
"addProject": "Add a new project"
"addProject": "Add a new project",
"label": "Project",
"browse": "Browse projects",
"listLabel": "Projects",
"noProjects": "No projects yet."
},
"RunTargetCombobox": {
"listLabel": "Run targets",
"label": "Run on",
"browse": "Browse run targets"
}
}
},

View File

@ -11388,9 +11388,17 @@
"placeholder": "选择主机"
},
"ProjectCombobox": {
"search": "搜索项目...",
"empty": "没有匹配搜索的项目。",
"addProject": "Add a new project"
"addProject": "Add a new project",
"label": "Project",
"browse": "Browse projects",
"listLabel": "Projects",
"noProjects": "No projects yet."
},
"RunTargetCombobox": {
"listLabel": "Run targets",
"label": "Run on",
"browse": "Browse run targets"
}
}
},