diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx
index 35c7e2184..e7001fcc7 100644
--- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx
+++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx
@@ -280,15 +280,18 @@ function changeInputValue(input: HTMLInputElement, value: string): void {
}
function openRunTargetPicker(container: HTMLElement): void {
- const runTargetButton = container.querySelector('button[role="combobox"]')
- expect(runTargetButton).toBeTruthy()
- act(() => runTargetButton?.click())
+ // The field is the search box; clicking its shell focuses it and opens the list.
+ const runTargetShell = container.querySelector(
+ '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('[cmdk-item], [data-run-target-add-host]')
+ ...document.body.querySelectorAll('[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('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('[cmdk-item]')].find(
+ const recipeItem = [...document.body.querySelectorAll('[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('button[role="combobox"]')
- expect(runTargetButton?.textContent).toContain('Per-Workspace Environment')
- act(() => runTargetButton?.click())
+ const runTargetShell = current.container.querySelector(
+ 'div[data-run-target-combobox-root="true"]'
+ )
+ expect(runTargetShell?.textContent).toContain('Per-Workspace Environment')
+ openRunTargetPicker(current.container)
- const builderItem = [...document.body.querySelectorAll('[cmdk-item]')].find(
+ const builderItem = [...document.body.querySelectorAll('[role="option"]')].find(
(item) => item.textContent?.includes('Builder')
)
expect(builderItem).toBeTruthy()
diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx
index 4e89e8b0e..edf657c31 100644
--- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx
+++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx
@@ -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(promise: Promise): Promise {
}
}
-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
-}
-
-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 | null>(null)
- const pointerInsideRef = React.useRef(false)
- const [position, setPosition] = React.useState(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): 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. */}
-
- {path}
-
- {/* Why: a fixed, pointer-transparent portal cannot reflow cmdk or become the hover target. */}
- {position
- ? createPortal(
-
- {path}
-
,
- 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
-}
-
-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>(
- () => 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 => {
- 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 (
-
-
-
- {selectedRecipe ? (
-
-
-
- {ephemeralVmLabel} / {selectedRecipe.name}
-
-
- ) : selectedHost ? (
-
-
- {selectedHost.label}
-
- ) : (
-
- {translate(
- 'auto.components.NewWorkspaceComposerCard.chooseRunTarget',
- 'Choose target'
- )}
-
- )}
-
-
-
-
-
-
-
- {translate(
- 'auto.components.NewWorkspaceComposerCard.noRunTargets',
- 'No run targets are ready for this project.'
- )}
-
- {readyHostOptions.map((option) => (
- handleHostSelect(option.id)}
- onPointerEnter={closeSubmenus}
- onFocus={closeSubmenus}
- className="items-center gap-2 px-3 py-1.5"
- >
-
-
-
-
- ))}
- {/* 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 ? (
- <>
-
- {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.
- {}}
- onPointerEnter={closeSubmenus}
- onFocus={closeSubmenus}
- className="items-center gap-2 px-3 py-1.5"
- >
-
-
- {/* 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) ? (
-
- ) : option.attention ? (
-
- ) : (
-
- )}
-
-
{option.label}
-
- {option.detail}
-
-
-
- {option.connectAction && onConnectHost ? (
- {
- 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) ? (
- <>
-
- {translate(
- 'auto.components.NewWorkspaceComposerCard.connectingHost',
- 'Connecting…'
- )}
- >
- ) : (
- translate(
- 'auto.components.NewWorkspaceComposerCard.connectHost',
- 'Connect'
- )
- )}
-
- ) : null}
-
- ))}
- >
- ) : 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) ? (
-
- ) : null}
- {recipes.length > 0 ? (
-
-
- {/* Why: a real CommandItem (not a raw button) so cmdk registers it — fixes missing rows, uneven height, and double-highlight. */}
-
-
-
-
-
{ephemeralVmLabel}
- {/* Why: a second line so this row matches the two-line host options above and hints what it opens. */}
-
- {translate(
- 'auto.components.NewWorkspaceComposerCard.perWorkspaceEnvHint',
- 'Provision an on-demand environment from a recipe'
- )}
-
-
-
-
-
-
-
-
- {recipes.map((recipe) => (
- handleRecipeSelect(recipe.id)}
- className="items-center gap-2 px-3 py-1.5"
- >
-
-
-
{recipe.name}
-
- {getRecipeCommandDisplay(recipe.create)} ·{' '}
- {getRecipeDestroyLabel(recipe)}
-
- {recipe.description ? (
-
- {recipe.description}
-
- ) : null}
-
-
- ))}
-
-
-
-
- ) : null}
-
- {/* 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. */}
-
-
- {/* 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. */}
-
-
-
-
- {translate('auto.components.NewWorkspaceComposerCard.addHost', 'Add host')}
-
-
-
-
-
- {/* 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. */}
-
-
-
-
-
-
- {translate(
- 'auto.components.NewWorkspaceComposerCard.addSshHost',
- 'Add SSH host'
- )}
-
-
- {translate(
- 'auto.components.NewWorkspaceComposerCard.addSshHostHint',
- 'Use an existing machine over SSH'
- )}
-
-
-
-
-
-
-
- {translate(
- 'auto.components.NewWorkspaceComposerCard.addRemoteOrcaServer',
- 'Add Remote Orca Server'
- )}
-
-
- {translate(
- 'auto.components.NewWorkspaceComposerCard.addRemoteOrcaServerHint',
- 'Pair another Orca runtime'
- )}
-
-
-
-
-
-
-
-
-
-
-
- )
-}
-
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({
) : null}
{shouldShowRunTargetPicker ? (
-
+ // 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.
+
{translate('auto.components.NewWorkspaceComposerCard.runOn', 'Run on')}
-
({
- Popover: ({ children }: { children: React.ReactNode }) => {children}
,
- PopoverContent: ({ children }: { children: React.ReactNode }) => {children}
,
- PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}>
-}))
-
-vi.mock('@/components/ui/command', () => ({
- Command: ({ children }: { children: React.ReactNode }) => {children}
,
- CommandEmpty: ({ children }: { children: React.ReactNode }) => {children}
,
- CommandInput: React.forwardRef>(
- (props, ref) =>
- ),
- CommandList: ({ children }: { children: React.ReactNode }) => {children}
,
- CommandItem: ({
+ Popover: ({
children,
- onSelect,
- value
+ onOpenChange
}: {
children: React.ReactNode
- onSelect?: (value: string) => void
- value: string
+ onOpenChange?: (open: boolean) => void
}) => (
- onSelect?.(value)}>
+
+ onOpenChange?.(false)} />
{children}
-
- )
+
+ ),
+ PopoverAnchor: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ PopoverContent: ({ children }: { children: React.ReactNode }) => {children}
}))
+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(
+ '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('[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( )
+ })
+
+ const trigger = container.querySelector(
+ '[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('[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('[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('button')).find(
- (button) => button.textContent?.includes('Add a new project')
+ const addRow = Array.from(container.querySelectorAll('[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(
+
+ )
+ })
+ openList()
+ type('zzzznomatch')
+
+ expect(container.textContent).toContain('No projects match your search.')
+ const addRow = Array.from(container.querySelectorAll('[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( )
})
+ openList()
expect(container.textContent).not.toContain('Add a new project')
})
@@ -198,8 +270,163 @@ describe('ProjectCombobox', () => {
)
})
+ 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( )
+ })
+ 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(
+
+ )
+ })
+ 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(
+
+ )
+ })
+ 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(
+
+ )
+ })
+ 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('[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(
+
+ )
+ })
+ openList()
+ type('asasdasd')
+ expect(field().value).toBe('asasdasd')
+
+ // Blur / outside-click closes via Radix, not the key handler.
+ act(() => {
+ container
+ .querySelector('[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(
+
+ )
+ })
+
+ 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(
+
+ )
+ })
+ 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
+ }
+ }
+ })
})
diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx
index f1dcea89f..9f29eb4c4 100644
--- a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx
+++ b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx
@@ -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(null)
- const focusFrameRef = React.useRef(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): 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): 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 (
-
-
+ {
+ 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' ? (
-
-
- {selectedProject.displayName}
-
- ) : (
-
- )
- ) : (
-
{placeholder}
- )}
-
-
-
+
+ {committed && selected ? : null}
+
+
+
{
+ 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 ? (
+
+ {/* 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. */}
+
+
+ {selected.displayName}
+
+
+
+
+ ) : null}
+
+
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"
+ >
+
+
+
+
{
- 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()
+ }
}}
>
-
-
-
-
- {translate(
- 'auto.components.new.workspace.ProjectCombobox.empty',
- 'No projects match your search.'
- )}
-
- {filteredOptions.map((option) => (
- handleSelect(option.id)}
- className="items-center gap-2 px-3 py-1.5"
- >
-
-
- {option.kind === 'project-group' ? (
-
-
- {option.displayName}
-
- ) : (
-
- )}
-
- {option.detail}
-
-
-
+ {/* The listbox wraps a scrolling pane plus the pinned Add row, so both
+ stay `option` children of one listbox. */}
+
+ {/* 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. */}
+
+ {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.
+
+ {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.'
+ )}
+
+ ) : 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.
+
+ {section.heading ? (
+
+ {section.heading}
+
+ ) : null}
+ {section.items.map((scored) => (
+
arm(scored.option.id)}
+ onCommit={() => commit(scored.option.id)}
+ />
+ ))}
+
))}
-
+
{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."
-
-
event.preventDefault()}
- onMouseEnter={() => setCommandValue('')}
- className="h-8 w-full justify-start rounded-none px-3 text-xs font-normal"
- >
-
-
- {translate(
- 'auto.components.new.workspace.ProjectCombobox.addProject',
- 'Add a new project'
- )}
-
-
+
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'
+ )}
+ >
+
+
+ {translate(
+ 'auto.components.new.workspace.ProjectCombobox.addProject',
+ 'Add a new project'
+ )}
+
) : null}
-
+
)
diff --git a/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx b/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx
new file mode 100644
index 000000000..e031bed5c
--- /dev/null
+++ b/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx
@@ -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' ? (
+
+ ) : (
+ // Square, matching the mark everywhere else (jump palette, sidebar).
+
+ )
+}
+
+/** 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
{text}
+ }
+ return (
+
+ {[...text].map((char, index) =>
+ marks.has(index) ? (
+
+ {char}
+
+ ) : (
+ char
+ )
+ )}
+
+ )
+}
+
+/**
+ * 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 (
+
+ {hits ? : detail}
+
+ )
+ }
+ return (
+
+ {/* Head collapses first; the tail only truncates once the head is gone. */}
+ {split.head}
+ /{split.tail}
+
+ )
+}
+
+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 (
+
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. */}
+
+
+
+ {/* Name keeps up to half the row; the path absorbs the rest so a deep
+ path can't squeeze the name down to "chec…". */}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx b/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx
new file mode 100644
index 000000000..4b3d0a07e
--- /dev/null
+++ b/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx
@@ -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
+}
+
+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>(() => 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 => {
+ 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): 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 (
+
+ {
+ 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}
+ />
+ 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()
+ }
+ }}
+ >
+
+
+ {rows.filter((row) => row.kind !== 'add-host').length === 0 ? (
+
+ {translate(
+ 'auto.components.NewWorkspaceComposerCard.noRunTargets',
+ 'No run targets are ready for this project.'
+ )}
+
+ ) : 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 (
+
}
+ 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 (
+
+ }
+ 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 ? (
+
void connectHost(row.option)}
+ />
+ ) : undefined
+ }
+ />
+ )
+ }
+ // Recipes submenu row.
+ return (
+ setSubmenu(next ? 'recipes' : null)}
+ armed={isArmed}
+ optionId={optionId}
+ recipes={matchedRecipes}
+ selectedRecipeId={selectedRecipe?.id ?? null}
+ onArm={() => {
+ arm(row.key)
+ setSubmenu('recipes')
+ }}
+ onSelectRecipe={selectRecipe}
+ />
+ )
+ })}
+
+ {hasAddHost ? (
+
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}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx b/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx
new file mode 100644
index 000000000..fb5fa811f
--- /dev/null
+++ b/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx
@@ -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
+}
+
+/**
+ * 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 (
+ 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. */}
+
+ {icon}
+
+ {stacked ? (
+
+ {label}
+ {detail}
+
+ ) : (
+ <>
+
+ {label}
+
+
+ >
+ )}
+ {trailing}
+ {submenu ? (
+
+
+
+ ) : null}
+
+ )
+}
+
+/** 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
+ }
+ if (attention) {
+ return
+ }
+ return
+}
+
+/** Inline Connect action on a disconnected host row. */
+export function ConnectHostButton({
+ connecting,
+ onConnect
+}: {
+ connecting: boolean
+ onConnect: () => void
+}): React.JSX.Element {
+ return (
+ {
+ 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 ? (
+ <>
+
+ {translate('auto.components.NewWorkspaceComposerCard.connectingHost', 'Connecting…')}
+ >
+ ) : (
+ translate('auto.components.NewWorkspaceComposerCard.connectHost', 'Connect')
+ )}
+
+ )
+}
diff --git a/src/renderer/src/components/new-workspace/RunTargetField.tsx b/src/renderer/src/components/new-workspace/RunTargetField.tsx
new file mode 100644
index 000000000..4694eae30
--- /dev/null
+++ b/src/renderer/src/components/new-workspace/RunTargetField.tsx
@@ -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
+ onKeyDown: (event: React.KeyboardEvent) => void
+}): React.JSX.Element {
+ return (
+
+ {
+ inputRef.current?.focus()
+ onOpenRequest()
+ }}
+ className={COMBOBOX_FIELD_SHELL}
+ >
+
+ {committed ? (
+ isRecipe ? (
+
+ ) : hostId ? (
+
+ ) : null
+ ) : null}
+
+
+
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 ? (
+
+ {/* Baseline-aligned: centring two type sizes leaves the smaller high. */}
+
+ {label}
+
+ {detail}
+
+
+
+ ) : null}
+
+
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"
+ >
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/new-workspace/RunTargetSubmenus.tsx b/src/renderer/src/components/new-workspace/RunTargetSubmenus.tsx
new file mode 100644
index 000000000..ebb41f0c4
--- /dev/null
+++ b/src/renderer/src/components/new-workspace/RunTargetSubmenus.tsx
@@ -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(null)
+ return (
+
+
+
+ }
+ 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)}
+ />
+
+
+ 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. */}
+ setHoveredKey(null)}
+ >
+ {recipes.map((recipe) => (
+ }
+ label={recipe.name}
+ detail={getRecipeDetail(recipe)}
+ armed={hoveredKey === recipe.id}
+ current={recipe.id === selectedRecipeId}
+ optionId={undefined}
+ onArm={() => setHoveredKey(recipe.id)}
+ onCommit={() => onSelectRecipe(recipe.id)}
+ />
+ ))}
+
+
+
+ )
+}
+
+/**
+ * "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(null)
+ const addHostLabel = translate('auto.components.NewWorkspaceComposerCard.addHost', 'Add host')
+ return (
+
+
+ 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'
+ )}
+ >
+
+
{addHostLabel}
+
+
+
+
+
+ event.preventDefault()}
+ >
+ setHoveredKey(null)}>
+ {onAddSshHost ? (
+ }
+ 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 ? (
+ }
+ 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}
+
+
+
+ )
+}
+
+export { RUN_TARGET_ADD_HOST_KEY }
diff --git a/src/renderer/src/components/new-workspace/project-combobox-matching.test.ts b/src/renderer/src/components/new-workspace/project-combobox-matching.test.ts
new file mode 100644
index 000000000..6e16dae38
--- /dev/null
+++ b/src/renderer/src/components/new-workspace/project-combobox-matching.test.ts
@@ -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()
+ })
+})
diff --git a/src/renderer/src/components/new-workspace/project-combobox-matching.ts b/src/renderer/src/components/new-workspace/project-combobox-matching.ts
new file mode 100644
index 000000000..edbd5595e
--- /dev/null
+++ b/src/renderer/src/components/new-workspace/project-combobox-matching.ts
@@ -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 {
+ const counts = new Map()
+ 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('/') }
+}
diff --git a/src/renderer/src/components/new-workspace/run-target-options.ts b/src/renderer/src/components/new-workspace/run-target-options.ts
new file mode 100644
index 000000000..a07a094cf
--- /dev/null
+++ b/src/renderer/src/components/new-workspace/run-target-options.ts
@@ -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[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 }
+}
diff --git a/src/renderer/src/components/new-workspace/type-ahead-combobox-styles.ts b/src/renderer/src/components/new-workspace/type-ahead-combobox-styles.ts
new file mode 100644
index 000000000..71a3122b1
--- /dev/null
+++ b/src/renderer/src/components/new-workspace/type-ahead-combobox-styles.ts
@@ -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 ` ` — 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)]'
diff --git a/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts b/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts
new file mode 100644
index 000000000..44fddd40d
--- /dev/null
+++ b/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts
@@ -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 {
+ 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')
+ })
+})
diff --git a/src/renderer/src/components/new-workspace/use-recent-project-ids.ts b/src/renderer/src/components/new-workspace/use-recent-project-ids.ts
new file mode 100644
index 000000000..3c68485ac
--- /dev/null
+++ b/src/renderer/src/components/new-workspace/use-recent-project-ids.ts
@@ -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()
+ 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])
+}
diff --git a/src/renderer/src/components/new-workspace/use-type-ahead-combobox.ts b/src/renderer/src/components/new-workspace/use-type-ahead-combobox.ts
new file mode 100644
index 000000000..2cf53d566
--- /dev/null
+++ b/src/renderer/src/components/new-workspace/use-type-ahead-combobox.ts
@@ -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
+ 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(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()
+ 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
+}
diff --git a/src/renderer/src/components/new-workspace/use-wheel-scrollable.ts b/src/renderer/src/components/new-workspace/use-wheel-scrollable.ts
new file mode 100644
index 000000000..c2c010e92
--- /dev/null
+++ b/src/renderer/src/components/new-workspace/use-wheel-scrollable.ts
@@ -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(): {
+ ref: MutableRefObject
+ setNode: (node: T | null) => void
+} {
+ const ref = useRef(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 }
+}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 8af57bc01..4e497d0f1 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -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"
}
}
},
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index d60f8704e..d3e0baf4e 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -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"
}
}
},
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index e625872db..1911a5a1c 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -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"
}
}
},
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 184d1aceb..653c41c32 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -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"
}
}
},
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 7f9a1ddfe..4b567a771 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -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"
}
}
},