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( - , - 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 ( - - - - - - - - - {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" - > - - -
-
{option.label}
- -
-
- ))} - {/* 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 ? ( - - ) : 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. */} - - - - - {/* 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. +
- ({ - 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 }) => ( - - ) +
+ ), + 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 ( - - - + + {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 ? ( + + ) : null} +
+ +
+ { - 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 ? ( + + ) : 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()} + 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 ( + + ) +} 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 ? ( + + ) : null} +
+ +
+
+ ) +} 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" } } },