From 05bbbb95826209fd5bdf54ad28d7769af279bfd4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:41:12 -0700 Subject: [PATCH] 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 --- ...orkspaceComposerProjectSection.fixture.tsx | 161 ++++++++++++++++ .../NewWorkspaceComposerProjectSection.tsx | 168 ++++++++--------- .../ProjectCombobox.dialog-handoff.test.tsx | 9 +- .../new-workspace/ProjectCombobox.test.tsx | 41 +++-- .../new-workspace/ProjectCombobox.tsx | 168 +++++++++-------- .../new-workspace/ProjectComboboxRow.tsx | 31 ++-- .../RunTargetCombobox.host-identity.test.tsx | 7 +- .../new-workspace/RunTargetComboboxRow.tsx | 15 +- .../new-workspace/RunTargetField.tsx | 173 +++++++++--------- .../new-workspace/RunTargetSubmenus.tsx | 6 +- 10 files changed, 486 insertions(+), 293 deletions(-) create mode 100644 src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.fixture.tsx diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.fixture.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.fixture.tsx new file mode 100644 index 00000000000..b4b91dc4cd5 --- /dev/null +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.fixture.tsx @@ -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 ( +
+

New worktree

+ {}} + 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" + /> +
+ ) +} + +export const closed = defineFixture({ width: 560, render: () => }) +export const narrow = defineFixture({ width: 360, render: () => }) +export const folderOnly = defineFixture({ + width: 560, + render: () => +}) +export const projectMenu = defineFixture({ + ...closed, + interact: (frame) => { + frame.querySelector('[aria-label="Project"][role="combobox"]')?.focus() + } +}) +export const hostMenu = defineFixture({ + ...closed, + interact: (frame) => { + frame.querySelector('[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') diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx index 111a4446a9e..ee91406df7b 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx @@ -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 (
-
-
- - {showAddProjectButton ? ( - - - + + + {translate('auto.components.NewWorkspaceComposerCard.d6b0a96f32', 'Add project')} + + + ) : null} +
+
+ + {projectError ? ( +

+ {projectError} +

+ ) : projectOptions.length === 0 ? ( +

+ {emptyProjectMessage ?? + translate( + 'auto.components.NewWorkspaceComposerCard.addProjectBeforeWorkspace', + 'Add a project before creating a workspace.' )} - > - - - - - {translate('auto.components.NewWorkspaceComposerCard.d6b0a96f32', 'Add project')} - - - ) : null} -

-
- - {projectError ? ( -

- {projectError} -

- ) : projectOptions.length === 0 ? ( -

- {emptyProjectMessage ?? - translate( - 'auto.components.NewWorkspaceComposerCard.addProjectBeforeWorkspace', - 'Add a project before creating a workspace.' - )} -

- ) : null} +

+ ) : null} +
+ {shouldShowRunTargetPicker ? ( +
+ + + {ephemeralVmRecipeError ? ( +

+ {ephemeralVmRecipeError} +

+ ) : null} +
+ ) : null} - {shouldShowRunTargetPicker ? ( -
- - - {ephemeralVmRecipeError ? ( -

- {ephemeralVmRecipeError} -

- ) : null} -
- ) : ephemeralVmRecipeError ? ( -

{ephemeralVmRecipeError}

+ {!shouldShowRunTargetPicker && ephemeralVmRecipeError ? ( +

{ephemeralVmRecipeError}

) : null} {selectedRepoRequiresConnection && selectedRepoConnectionId ? (
({ 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()) + await act(async () => + root.render( + + + + ) + ) await act(async () => projectField().click()) expect(document.querySelector('[role="listbox"]')).not.toBeNull() diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx index df81579f368..37677dd5678 100644 --- a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx @@ -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({element}) +} + 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( ) }) @@ -121,7 +126,7 @@ describe('ProjectCombobox', () => { it('keeps a focusable combobox that composer focus helpers can target', () => { act(() => { - root.render() + renderField() }) const trigger = container.querySelector( @@ -138,7 +143,7 @@ describe('ProjectCombobox', () => { const onValueChange = vi.fn() act(() => { - root.render( + renderField( { const onValueChange = vi.fn() act(() => { - root.render( + renderField( { const onAddProject = vi.fn() act(() => { - root.render( + renderField( { const onAddProject = vi.fn() act(() => { - root.render( + renderField( { it('omits the "Add project" action when no handler is provided', () => { act(() => { - root.render() + renderField() }) openList() @@ -264,7 +269,7 @@ describe('ProjectCombobox', () => { ] act(() => { - root.render( + renderField( ) }) @@ -276,7 +281,7 @@ describe('ProjectCombobox', () => { it('filters as the user types, without a second search box', () => { act(() => { - root.render() + renderField() }) openList() expect(container.querySelectorAll('input')).toHaveLength(1) @@ -291,7 +296,7 @@ describe('ProjectCombobox', () => { const onValueSelected = vi.fn() act(() => { - root.render( + renderField( { const onValueChange = vi.fn() act(() => { - root.render( + renderField( { it('restores the committed project on Escape instead of stranding a stale query', () => { act(() => { - root.render( + renderField( ) }) @@ -361,7 +366,7 @@ describe('ProjectCombobox', () => { it('drops an uncommitted query when the list closes, so junk text never persists', () => { act(() => { - root.render( + renderField( ) }) @@ -382,7 +387,7 @@ describe('ProjectCombobox', () => { it('marks the field invalid so a failed create press can turn it red', () => { act(() => { - root.render( + renderField( { it('arms the first rendered row when sections reorder the list', () => { act(() => { - root.render( + renderField( ) }) @@ -437,7 +442,7 @@ describe('ProjectCombobox', () => { const onValueChange = vi.fn() act(() => { - root.render( + renderField( ) }) @@ -456,7 +461,7 @@ describe('ProjectCombobox', () => { it('steps ArrowDown to the next rendered row instead of jumping across sections', () => { act(() => { - root.render( + renderField( ) }) @@ -471,7 +476,7 @@ describe('ProjectCombobox', () => { it('owns every option from the listbox, with no unroled wrapper in between', () => { act(() => { - root.render( + renderField( - -
{ - inputRef.current?.focus() - setOpen(true) - }} - className={cn( - COMBOBOX_FIELD_SHELL, - invalid && 'border-destructive ring-destructive/20 dark:ring-destructive/40', - triggerClassName - )} - > - - {committed && selected ? : null} - -
- + + +
{ - 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 ? ( - - -
-
+ +
+ + + {committed && selected ? ( + +
{selected.displayName}
+
{selected.detail}
+
+ ) : null} + {/* Closing must remove the selector before a nested dialog takes focus. */} {open ? ( 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' )} > - + + + {translate( 'auto.components.new.workspace.ProjectCombobox.addProject', diff --git a/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx b/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx index e65941df148..9cf579f7669 100644 --- a/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx +++ b/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx @@ -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' )} > - + - {/* Reserve up to half the row for the name so deep paths cannot squeeze it away. */} - - + + + +
) } diff --git a/src/renderer/src/components/new-workspace/RunTargetCombobox.host-identity.test.tsx b/src/renderer/src/components/new-workspace/RunTargetCombobox.host-identity.test.tsx index b589f1f8e79..3e75ee0f0c3 100644 --- a/src/renderer/src/components/new-workspace/RunTargetCombobox.host-identity.test.tsx +++ b/src/renderer/src/components/new-workspace/RunTargetCombobox.host-identity.test.tsx @@ -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 }) =>
{children}
})) +function render(element: React.ReactNode): ReturnType { + return renderUi({element}) +} + const localOption: ReadyProjectHostSetupOption = { id: 'same-id', kind: 'ready', diff --git a/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx b/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx index 5f39483e4b7..e492811ac19 100644 --- a/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx +++ b/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx @@ -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({ > {icon} {stacked ? ( - + {label} - {detail} + {detail ? ( + + ) : null} ) : ( <> diff --git a/src/renderer/src/components/new-workspace/RunTargetField.tsx b/src/renderer/src/components/new-workspace/RunTargetField.tsx index 3d91b1efcca..76166dc1fc2 100644 --- a/src/renderer/src/components/new-workspace/RunTargetField.tsx +++ b/src/renderer/src/components/new-workspace/RunTargetField.tsx @@ -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 onKeyDown: (event: React.KeyboardEvent) => void }): React.JSX.Element { + const [tooltipOpen, setTooltipOpen] = useState(false) 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 ? ( - - -
-
+ +
+ + + {committed ? ( + +
{label}
+
{detail}
+
+ ) : null} + ) } diff --git a/src/renderer/src/components/new-workspace/RunTargetSubmenus.tsx b/src/renderer/src/components/new-workspace/RunTargetSubmenus.tsx index 715b06b670e..86e90e2997d 100644 --- a/src/renderer/src/components/new-workspace/RunTargetSubmenus.tsx +++ b/src/renderer/src/components/new-workspace/RunTargetSubmenus.tsx @@ -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' )} > - + + + {addHostLabel}