Compact worktree project and host pickers with stacked details (#34098)

* Compact worktree project and host pickers with stacked details

* Align worktree picker markers and action gutters

* Extract WSL connector fixture to satisfy file length limit

* Restart CI after resolving upstream fixture conflict
This commit is contained in:
Neil
2026-10-26 17:41:12 -07:00
committed by GitHub
parent 77130982fa
commit 05bbbb9582
10 changed files with 486 additions and 293 deletions
@@ -0,0 +1,161 @@
import { useState } from 'react'
import { defineFixture } from '../../component-fixtures/component-fixture'
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
import {
getProjectHostSetupSelectionKey,
type ProjectHostSetupOption
} from '@/lib/project-host-setup-options'
import { NewWorkspaceComposerProjectSection } from './NewWorkspaceComposerProjectSection'
const projects: NewWorkspaceProjectOption[] = [
{
kind: 'project',
id: 'orca',
projectId: 'orca',
displayName: 'orca',
badgeColor: 'var(--muted-foreground)',
detail: 'stablyai/orca'
},
{
kind: 'project',
id: 'personal-orca',
projectId: 'personal-orca',
displayName: 'orca',
badgeColor: 'var(--muted-foreground)',
detail: 'personal/orca'
},
{
kind: 'project',
id: 'api',
projectId: 'api',
displayName: 'platform-api',
badgeColor: 'var(--muted-foreground)',
detail: 'stablyai/platform-api'
},
{
kind: 'project-group',
id: 'folder',
projectGroupId: 'folder',
displayName: 'Design workspace',
badgeColor: 'var(--muted-foreground)',
detail: '/Users/alice/Projects/design',
parentPath: '/Users/alice/Projects/design',
connectionId: null
}
]
const hosts: ProjectHostSetupOption[] = [
{
kind: 'ready',
id: 'local',
projectId: 'orca',
repoId: 'orca-local',
hostId: 'local',
label: 'MacBook Pro',
detail: 'Local',
path: '/Users/alice/Projects/orca'
},
{
kind: 'ready',
id: 'remote',
projectId: 'orca',
repoId: 'orca-remote',
hostId: 'ssh:devbox',
label: 'Dev server',
detail: 'SSH',
path: '/home/alice/projects/orca'
},
{
kind: 'needs-setup',
id: 'windows',
projectId: 'orca',
hostId: 'runtime:windows',
label: 'Windows workstation',
detail: 'Project location not set',
isAvailable: true,
attention: false,
canSetLocation: true
}
]
function ProjectAndHostPreview({
folderOnly = false
}: {
folderOnly?: boolean
}): React.JSX.Element {
const [project, setProject] = useState(folderOnly ? 'folder' : 'orca')
const [host, setHost] = useState('local')
return (
<div className="space-y-4 rounded-xl border border-border bg-background p-4">
<h1 className="text-base font-semibold">New worktree</h1>
<NewWorkspaceComposerProjectSection
projectError={null}
selectedRepoConnectionId={null}
selectedRepoRequiresConnection={false}
selectedRepoConnectInProgress={false}
projectOptions={projects}
selectedProjectId={project}
onProjectChange={setProject}
projectDescriptionId="fixture-project-description"
onAddProject={() => {}}
focusNameInput={() => {}}
shouldShowRunTargetPicker={!folderOnly}
projectHostSetupOptions={hosts}
selectedProjectHostSetupId={host}
handleProjectHostSetupChange={(key) => {
const option = hosts.find(
(candidate) => getProjectHostSetupSelectionKey(candidate) === key
)
if (option) {
setHost(option.id)
}
}}
ephemeralVmRecipes={[]}
handleAddSshHost={() => {}}
handleAddRemoteServer={() => {}}
handleConnectRunTargetHost={async () => {}}
handleSetLocation={() => {}}
onConnectSelectedRepo={async () => {}}
sshStatusLabel="Connected"
connectButtonLabel="Connect"
selectedProjectName="orca"
/>
</div>
)
}
export const closed = defineFixture({ width: 560, render: () => <ProjectAndHostPreview /> })
export const narrow = defineFixture({ width: 360, render: () => <ProjectAndHostPreview /> })
export const folderOnly = defineFixture({
width: 560,
render: () => <ProjectAndHostPreview folderOnly />
})
export const projectMenu = defineFixture({
...closed,
interact: (frame) => {
frame.querySelector<HTMLInputElement>('[aria-label="Project"][role="combobox"]')?.focus()
}
})
export const hostMenu = defineFixture({
...closed,
interact: (frame) => {
frame.querySelector<HTMLInputElement>('[aria-label="Run on"][role="combobox"]')?.focus()
}
})
export const narrowProjectMenu = defineFixture({ ...projectMenu, width: 360 })
export const narrowHostMenu = defineFixture({ ...hostMenu, width: 360 })
function tooltipFixture(attribute: string) {
return defineFixture({
...closed,
interact: async (frame) => {
frame
.querySelector(`div[${attribute}]`)
?.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' }))
await new Promise((resolve) => setTimeout(resolve, 500))
}
})
}
export const projectTooltip = tooltipFixture('data-project-combobox-root')
export const hostTooltip = tooltipFixture('data-run-target-combobox-root')
@@ -5,6 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import ProjectCombobox from '@/components/new-workspace/ProjectCombobox'
import RunTargetCombobox from '@/components/new-workspace/RunTargetCombobox'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import type {
EphemeralVmRecipeOption,
NeedsProjectHostOption,
@@ -85,91 +86,94 @@ export function NewWorkspaceComposerProjectSection({
}: NewWorkspaceComposerProjectSectionProps): React.JSX.Element {
return (
<fieldset disabled={disabled} className="space-y-1">
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-muted-foreground">
{projectLabel ??
translate('auto.components.NewWorkspaceComposerCard.969a8bff66', 'Project')}
</label>
{showAddProjectButton ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onAddProject}
className="size-5 shrink-0 rounded-sm text-muted-foreground hover:text-foreground"
aria-label={translate(
'auto.components.NewWorkspaceComposerCard.d6b0a96f32',
'Add project'
<div className={cn('grid gap-3', shouldShowRunTargetPicker && 'grid-cols-2')}>
<div className="min-w-0 space-y-1">
<div className="flex h-5 items-center justify-between gap-2">
<label className="text-xs font-medium text-muted-foreground">
{projectLabel ??
translate('auto.components.NewWorkspaceComposerCard.969a8bff66', 'Project')}
</label>
{showAddProjectButton ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onAddProject}
className="size-5 shrink-0 rounded-sm text-muted-foreground hover:text-foreground"
aria-label={translate(
'auto.components.NewWorkspaceComposerCard.d6b0a96f32',
'Add project'
)}
>
<FolderPlus className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{translate('auto.components.NewWorkspaceComposerCard.d6b0a96f32', 'Add project')}
</TooltipContent>
</Tooltip>
) : null}
</div>
<div className="space-y-1" data-contextual-tour-target="workspace-creation-project">
<ProjectCombobox
options={projectOptions}
value={selectedProjectId}
onValueChange={onProjectChange}
onValueSelected={focusNameInput}
onAddProject={onAddProject}
placeholder={
projectPlaceholder ??
translate('auto.components.NewWorkspaceComposerCard.dccd26d4e4', 'Choose project')
}
triggerClassName="h-9 w-full border-input text-sm focus:border-ring focus:ring-[3px] focus:ring-ring/50"
invalid={Boolean(projectError)}
describedBy={projectDescriptionId}
/>
{projectError ? (
<p id={projectDescriptionId} className="text-[11px] text-destructive">
{projectError}
</p>
) : projectOptions.length === 0 ? (
<p id={projectDescriptionId} className="text-[11px] text-muted-foreground">
{emptyProjectMessage ??
translate(
'auto.components.NewWorkspaceComposerCard.addProjectBeforeWorkspace',
'Add a project before creating a workspace.'
)}
>
<FolderPlus className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{translate('auto.components.NewWorkspaceComposerCard.d6b0a96f32', 'Add project')}
</TooltipContent>
</Tooltip>
) : null}
</div>
<div className="space-y-1" data-contextual-tour-target="workspace-creation-project">
<ProjectCombobox
options={projectOptions}
value={selectedProjectId}
onValueChange={onProjectChange}
onValueSelected={focusNameInput}
onAddProject={onAddProject}
placeholder={
projectPlaceholder ??
translate('auto.components.NewWorkspaceComposerCard.dccd26d4e4', 'Choose project')
}
triggerClassName="h-9 w-full border-input text-sm focus:border-ring focus:ring-[3px] focus:ring-ring/50"
invalid={Boolean(projectError)}
describedBy={projectDescriptionId}
/>
{projectError ? (
<p id={projectDescriptionId} className="text-[11px] text-destructive">
{projectError}
</p>
) : projectOptions.length === 0 ? (
<p id={projectDescriptionId} className="text-[11px] text-muted-foreground">
{emptyProjectMessage ??
translate(
'auto.components.NewWorkspaceComposerCard.addProjectBeforeWorkspace',
'Add a project before creating a workspace.'
)}
</p>
) : null}
</p>
) : null}
</div>
</div>
{shouldShowRunTargetPicker ? (
<div className="min-w-0 space-y-1">
<label className="flex h-5 min-w-0 items-center truncate text-xs font-medium text-muted-foreground">
{translate('auto.components.NewWorkspaceComposerCard.runOn', 'Run on')}
</label>
<RunTargetCombobox
hostOptions={projectHostSetupOptions ?? []}
hostValue={selectedProjectHostSetupId ?? null}
hostExecutionHostId={selectedRepoExecutionHostId}
onHostChange={handleProjectHostSetupChange}
recipes={ephemeralVmRecipes}
recipeValue={selectedEphemeralVmRecipeId}
onRecipeChange={onEphemeralVmRecipeChange}
onAddSshHost={handleAddSshHost}
onAddRemoteServer={handleAddRemoteServer}
onConnectHost={handleConnectRunTargetHost}
onSetLocation={handleSetLocation}
/>
{ephemeralVmRecipeError ? (
<p className="whitespace-pre-line text-[11px] text-destructive">
{ephemeralVmRecipeError}
</p>
) : null}
</div>
) : null}
</div>
{shouldShowRunTargetPicker ? (
<div className="space-y-1 pt-3">
<label className="block min-w-0 truncate text-xs font-medium text-muted-foreground">
{translate('auto.components.NewWorkspaceComposerCard.runOn', 'Run on')}
</label>
<RunTargetCombobox
hostOptions={projectHostSetupOptions ?? []}
hostValue={selectedProjectHostSetupId ?? null}
hostExecutionHostId={selectedRepoExecutionHostId}
onHostChange={handleProjectHostSetupChange}
recipes={ephemeralVmRecipes}
recipeValue={selectedEphemeralVmRecipeId}
onRecipeChange={onEphemeralVmRecipeChange}
onAddSshHost={handleAddSshHost}
onAddRemoteServer={handleAddRemoteServer}
onConnectHost={handleConnectRunTargetHost}
onSetLocation={handleSetLocation}
/>
{ephemeralVmRecipeError ? (
<p className="whitespace-pre-line text-[11px] text-destructive">
{ephemeralVmRecipeError}
</p>
) : null}
</div>
) : ephemeralVmRecipeError ? (
<p className="whitespace-pre-line text-[11px] text-destructive">{ephemeralVmRecipeError}</p>
{!shouldShowRunTargetPicker && ephemeralVmRecipeError ? (
<p className="whitespace-pre-line text-2xs text-destructive">{ephemeralVmRecipeError}</p>
) : null}
{selectedRepoRequiresConnection && selectedRepoConnectionId ? (
<div
@@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import ProjectCombobox from './ProjectCombobox'
import { TooltipProvider } from '@/components/ui/tooltip'
vi.mock('./use-recent-project-ids', () => ({ useRecentProjectIds: () => [] }))
@@ -101,7 +102,13 @@ describe.each([false, true])(
it.each(['mouse', 'keyboard'] as const)(
'removes the selector before the creation dialog is active via %s',
async (input) => {
await act(async () => root.render(<Composer populated={populated} />))
await act(async () =>
root.render(
<TooltipProvider>
<Composer populated={populated} />
</TooltipProvider>
)
)
await act(async () => projectField().click())
expect(document.querySelector('[role="listbox"]')).not.toBeNull()
@@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
import ProjectCombobox from './ProjectCombobox'
import { TooltipProvider } from '@/components/ui/tooltip'
// Render inline and expose Radix-style close independently of the component key handler.
vi.mock('@/components/ui/popover', () => ({
@@ -29,6 +30,10 @@ vi.mock('./use-recent-project-ids', () => ({ useRecentProjectIds: () => [] }))
let container: HTMLDivElement
let root: Root
function renderField(element: React.ReactNode): void {
root.render(<TooltipProvider>{element}</TooltipProvider>)
}
const projects: NewWorkspaceProjectOption[] = [
{
kind: 'project',
@@ -109,7 +114,7 @@ afterEach(() => {
describe('ProjectCombobox', () => {
it('renders a logical project label without host-specific SSH chrome', () => {
act(() => {
root.render(
renderField(
<ProjectCombobox options={projects} value="github:stablyai/orca" onValueChange={vi.fn()} />
)
})
@@ -121,7 +126,7 @@ describe('ProjectCombobox', () => {
it('keeps a focusable combobox that composer focus helpers can target', () => {
act(() => {
root.render(<ProjectCombobox options={projects} value={null} onValueChange={vi.fn()} />)
renderField(<ProjectCombobox options={projects} value={null} onValueChange={vi.fn()} />)
})
const trigger = container.querySelector<HTMLElement>(
@@ -138,7 +143,7 @@ describe('ProjectCombobox', () => {
const onValueChange = vi.fn()
act(() => {
root.render(
renderField(
<ProjectCombobox
options={projects}
value="github:stablyai/orca"
@@ -158,7 +163,7 @@ describe('ProjectCombobox', () => {
const onValueChange = vi.fn()
act(() => {
root.render(
renderField(
<ProjectCombobox
options={projects}
value="project-group:folder-group"
@@ -183,7 +188,7 @@ describe('ProjectCombobox', () => {
const onAddProject = vi.fn()
act(() => {
root.render(
renderField(
<ProjectCombobox
options={[]}
value={null}
@@ -210,7 +215,7 @@ describe('ProjectCombobox', () => {
const onAddProject = vi.fn()
act(() => {
root.render(
renderField(
<ProjectCombobox
options={projects}
value={null}
@@ -236,7 +241,7 @@ describe('ProjectCombobox', () => {
it('omits the "Add project" action when no handler is provided', () => {
act(() => {
root.render(<ProjectCombobox options={projects} value={null} onValueChange={vi.fn()} />)
renderField(<ProjectCombobox options={projects} value={null} onValueChange={vi.fn()} />)
})
openList()
@@ -264,7 +269,7 @@ describe('ProjectCombobox', () => {
]
act(() => {
root.render(
renderField(
<ProjectCombobox options={duplicateProjects} value={null} onValueChange={vi.fn()} />
)
})
@@ -276,7 +281,7 @@ describe('ProjectCombobox', () => {
it('filters as the user types, without a second search box', () => {
act(() => {
root.render(<ProjectCombobox options={projects} value={null} onValueChange={vi.fn()} />)
renderField(<ProjectCombobox options={projects} value={null} onValueChange={vi.fn()} />)
})
openList()
expect(container.querySelectorAll('input')).toHaveLength(1)
@@ -291,7 +296,7 @@ describe('ProjectCombobox', () => {
const onValueSelected = vi.fn()
act(() => {
root.render(
renderField(
<ProjectCombobox
options={projects}
value={null}
@@ -315,7 +320,7 @@ describe('ProjectCombobox', () => {
const onValueChange = vi.fn()
act(() => {
root.render(
renderField(
<ProjectCombobox
options={projects}
value={null}
@@ -336,7 +341,7 @@ describe('ProjectCombobox', () => {
it('restores the committed project on Escape instead of stranding a stale query', () => {
act(() => {
root.render(
renderField(
<ProjectCombobox options={projects} value="github:stablyai/orca" onValueChange={vi.fn()} />
)
})
@@ -361,7 +366,7 @@ describe('ProjectCombobox', () => {
it('drops an uncommitted query when the list closes, so junk text never persists', () => {
act(() => {
root.render(
renderField(
<ProjectCombobox options={projects} value="github:stablyai/orca" onValueChange={vi.fn()} />
)
})
@@ -382,7 +387,7 @@ describe('ProjectCombobox', () => {
it('marks the field invalid so a failed create press can turn it red', () => {
act(() => {
root.render(
renderField(
<ProjectCombobox
options={projects}
value={null}
@@ -421,7 +426,7 @@ describe('ProjectCombobox', () => {
it('arms the first rendered row when sections reorder the list', () => {
act(() => {
root.render(
renderField(
<ProjectCombobox options={sectionedOptions} value={null} onValueChange={vi.fn()} />
)
})
@@ -437,7 +442,7 @@ describe('ProjectCombobox', () => {
const onValueChange = vi.fn()
act(() => {
root.render(
renderField(
<ProjectCombobox options={sectionedOptions} value={null} onValueChange={onValueChange} />
)
})
@@ -456,7 +461,7 @@ describe('ProjectCombobox', () => {
it('steps ArrowDown to the next rendered row instead of jumping across sections', () => {
act(() => {
root.render(
renderField(
<ProjectCombobox options={sectionedOptions} value={null} onValueChange={vi.fn()} />
)
})
@@ -471,7 +476,7 @@ describe('ProjectCombobox', () => {
it('owns every option from the listbox, with no unroled wrapper in between', () => {
act(() => {
root.render(
renderField(
<ProjectCombobox
options={projects}
value={null}
@@ -1,8 +1,9 @@
import { ImeInput } from '@/lib/ime-text-field'
import React, { useCallback, useMemo } from 'react'
import React, { useCallback, useMemo, useState } from 'react'
import { ChevronDown, FolderPlus } from 'lucide-react'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
import { translate } from '@/i18n/i18n'
import {
@@ -10,7 +11,7 @@ import {
rankProjectOptions,
sectionProjectOptions
} from './project-combobox-matching'
import { ProjectOptionDetail, ProjectOptionMark, ProjectOptionRow } from './ProjectComboboxRow'
import { 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'
@@ -42,6 +43,7 @@ export default function ProjectCombobox({
invalid = false,
describedBy
}: ProjectComboboxProps): React.JSX.Element {
const [tooltipOpen, setTooltipOpen] = useState(false)
const recentIds = useRecentProjectIds()
// Derive keys from the displayed sections: folder grouping changes raw rank order, so keyboard selection must follow what users see.
const deriveRowKeys = useCallback(
@@ -129,89 +131,91 @@ export default function ProjectCombobox({
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverAnchor asChild>
<div
data-project-combobox-root="true"
onClick={() => {
inputRef.current?.focus()
setOpen(true)
}}
className={cn(
COMBOBOX_FIELD_SHELL,
invalid && 'border-destructive ring-destructive/20 dark:ring-destructive/40',
triggerClassName
)}
>
<span className="flex w-4 shrink-0 items-center justify-center">
{committed && selected ? <ProjectOptionMark option={selected} /> : null}
</span>
<div className="relative min-w-0 flex-1 overflow-hidden">
<ImeInput
ref={inputRef}
type="text"
role="combobox"
<Tooltip open={tooltipOpen && committed && !open} onOpenChange={setTooltipOpen}>
<PopoverAnchor asChild>
<TooltipTrigger asChild>
<div
data-project-combobox-root="true"
aria-label={translate(
'auto.components.new.workspace.ProjectCombobox.label',
'Project'
)}
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={open && armedKey ? `${listId}-armed` : undefined}
aria-invalid={invalid ? true : undefined}
aria-describedby={describedBy}
value={query}
placeholder={committed ? '' : placeholder}
onChange={(event) => {
setQuery(event.target.value)
onClick={() => {
inputRef.current?.focus()
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'
COMBOBOX_FIELD_SHELL,
invalid && 'border-destructive ring-destructive/20 dark:ring-destructive/40',
triggerClassName
)}
/>
{/* Overlay name/path separately so they can shrink at different rates. */}
{committed && selected ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center text-sm"
>
{/* Baseline-align mixed text sizes; centering would lift the smaller detail. */}
<div className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 max-w-[50%] shrink truncate">
{selected.displayName}
</span>
<ProjectOptionDetail
detail={selected.detail}
className="min-w-0 flex-1 shrink-[999] justify-end text-right text-xs text-muted-foreground"
/>
</div>
>
<span className="flex w-4 shrink-0 items-center justify-center">
{committed && selected ? <ProjectOptionMark option={selected} /> : null}
</span>
<div className="relative min-w-0 flex-1 overflow-hidden">
<ImeInput
ref={inputRef}
type="text"
role="combobox"
data-project-combobox-root="true"
aria-label={translate(
'auto.components.new.workspace.ProjectCombobox.label',
'Project'
)}
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={open && armedKey ? `${listId}-armed` : undefined}
aria-invalid={invalid ? true : undefined}
aria-describedby={describedBy}
value={query}
placeholder={committed ? '' : placeholder}
onChange={(event) => {
setQuery(event.target.value)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
className={cn(
'w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground',
committed && 'text-transparent caret-foreground'
)}
/>
{committed && selected ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex min-w-0 items-center text-sm"
>
<span className="truncate">{selected.displayName}</span>
</div>
) : null}
</div>
) : null}
</div>
<button
type="button"
tabIndex={-1}
aria-label={translate(
'auto.components.new.workspace.ProjectCombobox.browse',
'Browse projects'
)}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
inputRef.current?.focus()
setOpen(!open)
}}
className="-mr-1 flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ChevronDown className={cn('size-3.5 transition-transform', open && 'rotate-180')} />
</button>
</div>
</PopoverAnchor>
<button
type="button"
tabIndex={-1}
aria-label={translate(
'auto.components.new.workspace.ProjectCombobox.browse',
'Browse projects'
)}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
inputRef.current?.focus()
setOpen(!open)
}}
className="-mr-1 flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ChevronDown
className={cn('size-3.5 transition-transform', open && 'rotate-180')}
/>
</button>
</div>
</TooltipTrigger>
</PopoverAnchor>
{committed && selected ? (
<TooltipContent side="top" sideOffset={4} className="max-w-96 break-all">
<div className="font-medium">{selected.displayName}</div>
<div>{selected.detail}</div>
</TooltipContent>
) : null}
</Tooltip>
{/* Closing must remove the selector before a nested dialog takes focus. */}
{open ? (
<PopoverContent
@@ -305,11 +309,13 @@ export default function ProjectCombobox({
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',
'flex h-9 shrink-0 cursor-default items-center gap-2 border-t border-border px-3 text-sm',
armedKey === ADD_PROJECT_KEY && 'bg-accent text-accent-foreground'
)}
>
<FolderPlus className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex w-4 shrink-0 items-center justify-center">
<FolderPlus className="size-3.5 text-muted-foreground" />
</span>
<span className="truncate">
{translate(
'auto.components.new.workspace.ProjectCombobox.addProject',
@@ -118,29 +118,26 @@ export function ProjectOptionRow({
onMouseMove={onArm}
onClick={onCommit}
className={cn(
// Baseline-align mixed text sizes so name/detail read as one line.
'flex h-8 cursor-default items-baseline gap-2 rounded-sm px-2 text-sm',
'flex cursor-default items-start gap-2 rounded-sm px-2 py-1.5 text-sm',
armed && 'bg-accent text-accent-foreground',
current && !armed && 'bg-accent/60'
)}
>
<span className="flex h-8 shrink-0 items-center">
<span className="flex h-5 w-4 shrink-0 items-center justify-center">
<ProjectOptionMark option={option} />
</span>
{/* Reserve up to half the row for the name so deep paths cannot squeeze it away. */}
<MatchedText
text={option.displayName}
hits={nameHits}
className={cn('max-w-[50%] shrink', current && 'font-medium')}
/>
<ProjectOptionDetail
detail={option.detail}
hits={detailHits}
className={cn(
'ml-auto min-w-0 flex-1 shrink-[999] justify-end pl-2 text-right text-xs',
ambiguous ? 'text-foreground/80' : 'text-muted-foreground'
)}
/>
<span className="flex min-w-0 flex-1 flex-col">
<MatchedText
text={option.displayName}
hits={nameHits}
className={current ? 'font-medium' : undefined}
/>
<ProjectOptionDetail
detail={option.detail}
hits={detailHits}
className={cn('text-xs', ambiguous ? 'text-foreground/80' : 'text-muted-foreground')}
/>
</span>
</div>
)
}
@@ -1,10 +1,11 @@
// @vitest-environment happy-dom
import React from 'react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render as renderUi, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ReadyProjectHostSetupOption } from '@/lib/project-host-setup-options'
import RunTargetCombobox from './RunTargetCombobox'
import { TooltipProvider } from '@/components/ui/tooltip'
import { buildRunTargetRows } from './run-target-options'
vi.mock('@/components/ui/popover', () => ({
@@ -13,6 +14,10 @@ vi.mock('@/components/ui/popover', () => ({
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
}))
function render(element: React.ReactNode): ReturnType<typeof renderUi> {
return renderUi(<TooltipProvider>{element}</TooltipProvider>)
}
const localOption: ReadyProjectHostSetupOption = {
id: 'same-id',
kind: 'ready',
@@ -6,7 +6,7 @@ import { HostRowIcon } from '../host-row-icon'
export { HostRowIcon }
/** Match project-picker baseline rows; stacked Add-host cards add explanatory descriptions. */
/** Match the project picker with details below each target name. */
export function RunTargetRow({
icon,
label,
@@ -16,7 +16,7 @@ export function RunTargetRow({
optionId,
dimmed = false,
submenu = false,
stacked = false,
stacked = true,
onArm,
onCommit,
trailing
@@ -58,17 +58,20 @@ export function RunTargetRow({
>
<span
className={cn(
'flex shrink-0 items-center',
stacked ? 'self-start pt-0.5' : 'h-8',
'flex w-4 shrink-0 items-center justify-center',
stacked ? 'h-5' : 'h-8',
stacked && detail && 'self-start',
dimmed && 'opacity-60'
)}
>
{icon}
</span>
{stacked ? (
<span className="flex min-w-0 flex-1 flex-col">
<span className={cn('flex min-w-0 flex-1 flex-col', dimmed && 'opacity-60')}>
<span className={cn('truncate', current && 'font-medium')}>{label}</span>
<span className="truncate text-xs text-muted-foreground">{detail}</span>
{detail ? (
<ProjectOptionDetail detail={detail} className="text-xs text-muted-foreground" />
) : null}
</span>
) : (
<>
@@ -1,14 +1,15 @@
import { ImeInput } from '@/lib/ime-text-field'
import React from 'react'
import React, { useState } from 'react'
import { ChevronDown, Cloud } from 'lucide-react'
import { PopoverAnchor } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
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'
/** Overlay committed label/path on the search input so they can shrink independently. */
/** Overlay the committed selection while keeping the input ready to search. */
export default function RunTargetField({
query,
onQueryChange,
@@ -40,92 +41,94 @@ export default function RunTargetField({
inputRef: React.RefObject<HTMLInputElement | null>
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
}): React.JSX.Element {
const [tooltipOpen, setTooltipOpen] = useState(false)
return (
<PopoverAnchor asChild>
<div
data-run-target-combobox-root="true"
onClick={() => {
inputRef.current?.focus()
onOpenRequest()
}}
className={COMBOBOX_FIELD_SHELL}
>
<span className="flex w-4 shrink-0 items-center justify-center">
{committed ? (
isRecipe ? (
<Cloud className="size-3.5 shrink-0 text-muted-foreground" />
) : hostId ? (
<HostRowIcon hostId={hostId} />
) : null
) : null}
</span>
<div className="relative min-w-0 flex-1 overflow-hidden">
<ImeInput
ref={inputRef}
type="text"
role="combobox"
<Tooltip open={tooltipOpen && committed && !open} onOpenChange={setTooltipOpen}>
<PopoverAnchor asChild>
<TooltipTrigger asChild>
<div
data-run-target-combobox-root="true"
aria-label={translate(
'auto.components.new.workspace.RunTargetCombobox.label',
'Run on'
)}
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={open && hasArmedRow ? `${listId}-armed` : undefined}
value={query}
placeholder={
committed
? ''
: translate(
'auto.components.NewWorkspaceComposerCard.chooseRunTarget',
'Choose target'
)
}
onChange={(event) => onQueryChange(event.target.value)}
onFocus={onOpenRequest}
onKeyDown={onKeyDown}
className={cn(
'w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground',
committed && 'text-transparent caret-foreground'
)}
/>
{committed ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center text-sm"
>
{/* Baseline-aligned: centring two type sizes leaves the smaller high. */}
<div className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 max-w-[50%] shrink truncate">{label}</span>
<span
className="min-w-0 flex-1 shrink-[999] truncate text-right text-xs text-muted-foreground"
title={detail}
onClick={() => {
inputRef.current?.focus()
onOpenRequest()
}}
className={COMBOBOX_FIELD_SHELL}
>
<span className="flex w-4 shrink-0 items-center justify-center">
{committed ? (
isRecipe ? (
<Cloud className="size-3.5 shrink-0 text-muted-foreground" />
) : hostId ? (
<HostRowIcon hostId={hostId} />
) : null
) : null}
</span>
<div className="relative min-w-0 flex-1 overflow-hidden">
<ImeInput
ref={inputRef}
type="text"
role="combobox"
data-run-target-combobox-root="true"
aria-label={translate(
'auto.components.new.workspace.RunTargetCombobox.label',
'Run on'
)}
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={open && hasArmedRow ? `${listId}-armed` : undefined}
value={query}
placeholder={
committed
? ''
: translate(
'auto.components.NewWorkspaceComposerCard.chooseRunTarget',
'Choose target'
)
}
onChange={(event) => onQueryChange(event.target.value)}
onFocus={onOpenRequest}
onKeyDown={onKeyDown}
className={cn(
'w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground',
committed && 'text-transparent caret-foreground'
)}
/>
{committed ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center text-sm"
>
{detail}
</span>
</div>
<span className="min-w-0 truncate">{label}</span>
</div>
) : null}
</div>
) : null}
</div>
<button
type="button"
tabIndex={-1}
aria-label={translate(
'auto.components.new.workspace.RunTargetCombobox.browse',
'Browse run targets'
)}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
inputRef.current?.focus()
onToggle()
}}
className="-mr-1 flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ChevronDown className={cn('size-3.5 transition-transform', open && 'rotate-180')} />
</button>
</div>
</PopoverAnchor>
<button
type="button"
tabIndex={-1}
aria-label={translate(
'auto.components.new.workspace.RunTargetCombobox.browse',
'Browse run targets'
)}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation()
inputRef.current?.focus()
onToggle()
}}
className="-mr-1 flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ChevronDown className={cn('size-3.5 transition-transform', open && 'rotate-180')} />
</button>
</div>
</TooltipTrigger>
</PopoverAnchor>
{committed ? (
<TooltipContent side="top" sideOffset={4} className="max-w-96 break-all">
<div className="font-medium">{label}</div>
<div>{detail}</div>
</TooltipContent>
) : null}
</Tooltip>
)
}
@@ -122,11 +122,13 @@ export function AddHostSubmenuRow({
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',
'flex h-9 shrink-0 cursor-default items-center gap-2 border-t border-border px-3 text-sm',
armed && 'bg-accent text-accent-foreground'
)}
>
<Plus className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex w-4 shrink-0 items-center justify-center">
<Plus className="size-3.5 text-muted-foreground" />
</span>
<span className="truncate">{addHostLabel}</span>
<span className="ml-auto flex shrink-0 items-center">
<ChevronDown className="size-3.5 -rotate-90 text-muted-foreground" />