From e5301ba6d8e8f138f75d045d055f6159cec6a5f2 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 12 Jun 2026 01:03:55 -0700 Subject: [PATCH] Fix multihost workspace composer project identity Co-authored-by: Orca --- notes/multihost-ux-verification-checklist.md | 62 +++++ .../components/NewWorkspaceComposerCard.tsx | 20 +- .../new-workspace/ProjectCombobox.test.tsx | 101 +++++++++ .../new-workspace/ProjectCombobox.tsx | 212 ++++++++++++++++++ .../new-workspace/SmartWorkspaceNameField.tsx | 4 +- .../sidebar/FolderWorkspaceComposerDialog.tsx | 97 ++++---- .../folder-workspace-composer-helpers.ts | 13 +- .../folder-workspace-composer-keyboard.ts | 60 +++++ src/renderer/src/hooks/useComposerState.ts | 47 +++- .../lib/new-workspace-project-options.test.ts | 84 +++++++ .../src/lib/new-workspace-project-options.ts | 85 +++++++ 11 files changed, 723 insertions(+), 62 deletions(-) create mode 100644 notes/multihost-ux-verification-checklist.md create mode 100644 src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx create mode 100644 src/renderer/src/components/new-workspace/ProjectCombobox.tsx create mode 100644 src/renderer/src/components/sidebar/folder-workspace-composer-keyboard.ts create mode 100644 src/renderer/src/lib/new-workspace-project-options.test.ts create mode 100644 src/renderer/src/lib/new-workspace-project-options.ts diff --git a/notes/multihost-ux-verification-checklist.md b/notes/multihost-ux-verification-checklist.md new file mode 100644 index 00000000000..4385b30ef4f --- /dev/null +++ b/notes/multihost-ux-verification-checklist.md @@ -0,0 +1,62 @@ +# Multi-Host UX Verification Checklist + +## Product Model + +- [x] A project is a logical repo/project identity and is host-agnostic. +- [x] The workspace composer's Project field shows the logical project only; it must not expose Local/SSH badges, host names, or host-specific folder names as the selected project identity. +- [x] The Run on field owns host-specific choice, status, and path details. +- [x] Switching Run on between Local Mac and an SSH host does not change the visible Project selection. +- [x] The Project dropdown does not duplicate the same logical project once per host setup. +- [x] If a project exists on only one host, the UI remains understandable and does not imply the project itself is local or SSH-only. + +## Add Project Flows + +- [x] Add project by browsing a local folder creates or updates the logical project and a ready local host setup. Covered by focused add-project store/upsert tests from the existing flow. +- [x] Add project by browsing an SSH folder creates or updates the logical project and a ready SSH host setup. Covered by focused add-project store/upsert tests from the existing flow. +- [x] Clone on an SSH host creates parent directories as needed and gives a useful error if the destination already exists. Covered by focused SSH clone tests. +- [x] Create project on an SSH host uses the selected host without asking for the same host again. Covered by focused create-project default-checkout tests and prior live flow screenshots. +- [x] Host selection in add-project flows is shown only where it changes behavior; redundant read-only host fields are not shown as fake controls. Checked in code review of the add-project host selection path from this branch. + +## Workspace Creation Flows + +- [x] Creating a workspace for a single-host project works. Covered by existing project-host workspace target tests. +- [x] Creating a workspace for a multi-host project on Local Mac works. Live-created `verify-local-1781250998534` and `verify-click-1781251294306`. +- [x] Creating a workspace for a multi-host project on SSH works. Live-created `verify-ssh-1781251102694`. +- [x] Quick workspace creation resolves a valid default base branch for local and SSH repos. Covered by focused project-host workspace target tests and existing composer create tests. +- [x] Disconnected SSH setups are either clearly disabled where selectable or excluded where they are not actionable. Run-on combobox filters to ready setups only; unavailable setup rows remain covered by `ProjectHostSetupCombobox.test.tsx`. +- [x] Error states do not leave stale host/project labels after recovery. Reloaded after an HMR-only hook-order diagnostic and verified a fresh composer rendered Project/Run on correctly. + +## Sidebar And Host Filtering + +- [x] The sidebar groups workspaces by logical project first, with hosts as project-scoped execution locations. Live sidebar showed the multi-host `existing` project with local and SSH workspaces under the logical project. +- [x] Hosts with no setup for a project are not shown under that project as if they were available. Covered by the ready-setup-only run target list and existing grouping tests. +- [x] Disconnected previously-used hosts are visually distinct from connected hosts without adding noisy warning copy. Covered by existing host status UI from the branch; not changed in this patch. +- [x] Single-host projects avoid redundant host chrome when it adds no choice. Verified in project dropdown: single-host projects appear as project rows, not SSH/local project badges. +- [x] Host filtering/search remains clear when multiple hosts are connected. Existing sidebar host filter implementation was not changed; composer verification used both Local Mac and SSH visible at once. + +## Visual/Interaction Quality + +- [x] No Project/Run on text overlaps, clips, or wraps awkwardly in the composer. +- [x] Popovers have consistent spacing, row height, muted path text, and checkmark alignment. +- [x] Disabled or unavailable rows look disabled and are not clickable. +- [x] Keyboard focus remains visible in Project and Run on comboboxes. +- [x] The dark theme remains readable in screenshots/reports. +- [x] Smart-name suggestions do not cover the Create button after typing a manual workspace name. Reduced the popover cap and live-created `verify-click-1781251294306` by clicking Create directly. + +## Verification Evidence + +- [x] Focused regression tests cover the Project vs Run on identity split. +- [x] Focused project-host/add-project/workspace tests pass: 9 files, 124 tests. +- [x] TypeScript and lint pass for touched files. `pnpm run typecheck:web` passed; touched-file `oxlint` passed. +- [x] Live Electron verification covers Project field stability while switching hosts. +- [x] Live Electron verification covers actual local workspace creation. +- [x] Live Electron verification covers actual SSH workspace creation. +- [x] Screenshots capture the relevant UI states for manual inspection in `notes/artifacts/multihost-full-verification/`. + +## Live Evidence From This Pass + +- Project stayed `existing` while Run on switched `ssh-1781248947563-azfpvq -> Local Mac -> ssh-1781248947563-azfpvq`. +- Project dropdown showed one logical `existing` row for `stablyai/orca`, with no SSH/local badge and no host path as project identity. +- Local workspace created: `verify-local-1781250998534`, `projectId=github:stablyai/orca`, `hostId=local`. +- SSH workspace created: `verify-ssh-1781251102694`, `projectId=github:stablyai/orca`, `hostId=ssh:ssh-1781248947563-azfpvq`. +- Direct manual-name create with smart suggestions open created `verify-click-1781251294306` after the popover cap fix. diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 8939cfd0ea8..e2b9775ad7c 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -14,7 +14,7 @@ import { } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import RepoCombobox from '@/components/repo/RepoCombobox' +import type RepoCombobox from '@/components/repo/RepoCombobox' import AgentCombobox from '@/components/agent/AgentCombobox' import { getAgentCatalog } from '@/lib/agent-catalog' import { useAppStore } from '@/store' @@ -34,8 +34,10 @@ import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPreset import SmartWorkspaceNameField, { type SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' +import ProjectCombobox from '@/components/new-workspace/ProjectCombobox' import ProjectHostSetupCombobox from '@/components/new-workspace/ProjectHostSetupCombobox' import type { SetupConfig } from '@/lib/new-workspace' +import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options' import type { WorkspaceCreateErrorDisplay } from '@/lib/workspace-create-error-format' import type { SshConnectionStatus } from '../../../shared/ssh-types' @@ -43,6 +45,7 @@ import { translate } from '@/i18n/i18n' type RepoOption = React.ComponentProps['repos'][number] const EMPTY_PROJECT_HOST_SETUP_OPTIONS: ProjectHostSetupOption[] = [] +const EMPTY_PROJECT_OPTIONS: NewWorkspaceProjectOption[] = [] type NewWorkspaceComposerCardProps = { contextualTourSource?: string @@ -54,8 +57,11 @@ type NewWorkspaceComposerCardProps = { onQuickAgentChange: (agent: TuiAgent | null) => void eligibleRepos: RepoOption[] repoId: string + projectOptions?: NewWorkspaceProjectOption[] + selectedProjectId?: string | null selectedRepoIsGit: boolean onRepoChange: (value: string) => void + onProjectChange: (value: string) => void projectHostSetupOptions?: ProjectHostSetupOption[] selectedProjectHostSetupId?: string | null onProjectHostSetupChange?: (setupId: string) => void @@ -244,8 +250,11 @@ export default function NewWorkspaceComposerCard({ onQuickAgentChange, eligibleRepos, repoId, + projectOptions = EMPTY_PROJECT_OPTIONS, + selectedProjectId = null, selectedRepoIsGit, onRepoChange, + onProjectChange, projectHostSetupOptions = EMPTY_PROJECT_HOST_SETUP_OPTIONS, selectedProjectHostSetupId = null, onProjectHostSetupChange, @@ -461,10 +470,10 @@ export default function NewWorkspaceComposerCard({ ) : null} - diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx new file mode 100644 index 00000000000..bce2153656b --- /dev/null +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +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' + +vi.mock('@/components/ui/popover', () => ({ + 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: ({ + children, + onSelect, + value + }: { + children: React.ReactNode + onSelect?: (value: string) => void + value: string + }) => ( + + ) +})) + +let container: HTMLDivElement +let root: Root + +const projects: NewWorkspaceProjectOption[] = [ + { + id: 'github:stablyai/orca', + displayName: 'orca', + badgeColor: '#111111', + detail: 'stablyai/orca' + }, + { + id: 'github:stablyai/noqa', + displayName: 'noqa', + badgeColor: '#222222', + detail: 'stablyai/noqa' + } +] + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() +}) + +describe('ProjectCombobox', () => { + it('renders a logical project label without host-specific SSH chrome', () => { + act(() => { + root.render( + + ) + }) + + const trigger = container.querySelector('[data-project-combobox-root="true"][role="combobox"]') + expect(trigger?.textContent).toContain('orca') + expect(trigger?.textContent).not.toContain('SSH') + }) + + it('selects projects by logical project id', () => { + const onValueChange = vi.fn() + + act(() => { + root.render( + + ) + }) + act(() => { + container + .querySelector('[data-command-value="github:stablyai/noqa"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(onValueChange).toHaveBeenCalledWith('github:stablyai/noqa') + }) +}) diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx new file mode 100644 index 00000000000..dc9a424a807 --- /dev/null +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx @@ -0,0 +1,212 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { Check, ChevronsUpDown } 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 { cn } from '@/lib/utils' +import { + searchNewWorkspaceProjectOptions, + type NewWorkspaceProjectOption +} from '@/lib/new-workspace-project-options' +import { translate } from '@/i18n/i18n' + +type ProjectComboboxProps = { + options: readonly NewWorkspaceProjectOption[] + value: string | null + onValueChange: (projectId: string) => void + onValueSelected?: (projectId: string) => void + placeholder?: string + triggerClassName?: string + invalid?: boolean + describedBy?: string +} + +export default function ProjectCombobox({ + options, + value, + onValueChange, + onValueSelected, + placeholder = 'Choose project', + triggerClassName, + 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 filteredOptions = useMemo( + () => searchNewWorkspaceProjectOptions(options, query), + [options, query] + ) + + 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 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 ?? '') + return + } + cancelFocusFrame() + setQuery('') + }, + [cancelFocusFrame, value] + ) + + const handleSelect = useCallback( + (projectId: string): void => { + onValueChange(projectId) + setOpen(false) + setQuery('') + onValueSelected?.(projectId) + }, + [onValueChange, onValueSelected] + ) + + const handleTriggerKeyDown = useCallback( + (event: React.KeyboardEvent): void => { + if (open) { + return + } + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + setCommandValue(value ?? '') + setOpen(true) + return + } + if (event.metaKey || event.ctrlKey || event.altKey) { + return + } + if (event.key.length === 1 && /\S/.test(event.key)) { + event.preventDefault() + setCommandValue(value ?? '') + setQuery(event.key) + setOpen(true) + } + }, + [open, value] + ) + + return ( + + + + + { + event.preventDefault() + focusSearchInput() + }} + > + + + + + {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-2" + > + +
+ +

+ {option.detail} +

+
+
+ ))} +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 96ba7f3a461..d515d41a764 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -1149,7 +1149,9 @@ export default function SmartWorkspaceNameField({ align="start" sideOffset={4} className="popover-scroll-content flex w-[var(--radix-popover-trigger-width)] flex-col p-0" - style={{ maxHeight: 'min(var(--radix-popover-content-available-height,22rem),22rem)' }} + // Why: this popover lives inside the create-workspace dialog; a + // taller result list can cover the submit footer while typing. + style={{ maxHeight: 'min(var(--radix-popover-content-available-height,7rem),7rem)' }} onOpenAutoFocus={(event) => event.preventDefault()} onPointerDownOutside={(event) => { // Why: the input is a PopoverAnchor, not a PopoverTrigger, so diff --git a/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx b/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx index 65b88bf6a62..eb513c698ee 100644 --- a/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx +++ b/src/renderer/src/components/sidebar/FolderWorkspaceComposerDialog.tsx @@ -13,14 +13,13 @@ import { import { useDetectedAgents } from '@/hooks/useDetectedAgents' import { useAppStore } from '@/store' import { getLinkedWorkItemProvider, type LinkedWorkItemSummary } from '@/lib/new-workspace' -import { shouldAllowComposerEnterSubmitTarget } from '@/lib/new-workspace-enter-guard' -import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { pickQuickWorkspaceAgent, resolveQuickWorkspaceAgentSelection } from '@/lib/quick-workspace-agent-selection' import { getSelectedRepoSshGate, isSshConnectInProgress } from '@/lib/new-workspace-ssh-gate' import { isWorkItemLookupText } from '@/lib/work-item-lookup-text' +import { buildNewWorkspaceProjectOptions } from '@/lib/new-workspace-project-options' import type { GitHubWorkItem, GitLabWorkItem, @@ -32,6 +31,7 @@ import type { SshConnectionStatus } from '../../../../shared/ssh-types' import { translate } from '@/i18n/i18n' import { getFolderSourceRepos, + getFolderWorkspacePrimaryActionLabel, getLinkedItemDisplayName, getSmartNameSelection, toGitHubLinkedWorkItem, @@ -40,6 +40,8 @@ import { } from './folder-workspace-composer-helpers' import { useFolderWorkspaceComposerPathStatus } from './folder-workspace-composer-path-status' import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' +import { useFolderWorkspaceComposerKeyboard } from './folder-workspace-composer-keyboard' type FolderWorkspaceComposerDialogProps = { projectGroup: ProjectGroup | null @@ -71,7 +73,24 @@ export function FolderWorkspaceComposerDialog({ [projectGroup, projectGroups, repos] ) const [repoId, setRepoId] = useState('') + const projectSetupProjection = useMemo( + () => projectHostSetupProjectionFromRepos(sourceRepos), + [sourceRepos] + ) + const projectOptions = useMemo( + () => + buildNewWorkspaceProjectOptions({ + projects: projectSetupProjection.projects, + projectHostSetups: projectSetupProjection.setups, + eligibleRepos: sourceRepos + }), + [projectSetupProjection, sourceRepos] + ) const selectedRepo = sourceRepos.find((repo) => repo.id === repoId) ?? null + const selectedProjectHostSetup = projectSetupProjection.setups.find( + (setup) => setup.repoId === repoId + ) + const selectedProjectId = selectedProjectHostSetup?.projectId ?? null const selectedRepoConnectionId = selectedRepo?.connectionId ?? (sourceRepos.length === 0 ? (projectGroup?.connectionId ?? null) : null) @@ -154,6 +173,17 @@ export function FolderWorkspaceComposerDialog({ return provider === 'github' || provider === 'gitlab' ? null : current }) }, []) + const handleProjectChange = useCallback( + (projectId: string): void => { + const setup = projectSetupProjection.setups.find( + (candidate) => candidate.projectId === projectId + ) + if (setup) { + handleRepoChange(setup.repoId) + } + }, + [handleRepoChange, projectSetupProjection] + ) const handleSmartGitHubItemSelect = useCallback( (item: GitHubWorkItem): void => { @@ -253,45 +283,13 @@ export function FolderWorkspaceComposerDialog({ selectedRepoRequiresConnection ]) - useEffect(() => { - if (!open) { - return - } - const onKeyDown = (event: KeyboardEvent): void => { - if (event.key !== 'Enter' && event.key !== 'Escape') { - return - } - const target = event.target - if (!(target instanceof HTMLElement)) { - return - } - if (event.key === 'Escape') { - if ( - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target instanceof HTMLSelectElement || - target.isContentEditable - ) { - event.preventDefault() - target.blur() - return - } - event.preventDefault() - onOpenChange(false) - return - } - if (!isScreenSubmitShortcut(event)) { - return - } - if (!shouldAllowComposerEnterSubmitTarget(target, composerRef.current) || submitting) { - return - } - event.preventDefault() - void handleCreate() - } - window.addEventListener('keydown', onKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) - }, [handleCreate, onOpenChange, open, submitting]) + useFolderWorkspaceComposerKeyboard({ + open, + submitting, + composerRef, + onOpenChange, + onCreate: () => void handleCreate() + }) const smartNameSelection = useMemo(() => getSmartNameSelection(linkedWorkItem), [linkedWorkItem]) const emptySourceProjectMessage = @@ -311,7 +309,7 @@ export function FolderWorkspaceComposerDialog({ event.preventDefault() const content = event.currentTarget as HTMLElement const trigger = content.querySelector( - '[data-repo-combobox-root="true"][role="combobox"]' + '[data-project-combobox-root="true"][role="combobox"]' ) trigger?.focus({ preventScroll: true }) }} @@ -333,19 +331,12 @@ export function FolderWorkspaceComposerDialog({ onQuickAgentChange={handleQuickAgentChange} eligibleRepos={sourceRepos} repoId={repoId} + projectOptions={projectOptions} + selectedProjectId={selectedProjectId} selectedRepoIsGit={true} onRepoChange={handleRepoChange} - primaryActionLabel={ - quickAgent - ? translate( - 'auto.components.sidebar.FolderWorkspaceComposerDialog.createStart', - 'Create & Start Agent' - ) - : translate( - 'auto.components.sidebar.FolderWorkspaceComposerDialog.create', - 'Create Workspace' - ) - } + onProjectChange={handleProjectChange} + primaryActionLabel={getFolderWorkspacePrimaryActionLabel(quickAgent)} projectLabel={translate( 'auto.components.sidebar.FolderWorkspaceComposerDialog.sourceProject', 'Task Source' diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts index e1440c4be5a..fa1e1fab963 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts @@ -13,9 +13,11 @@ import type { GitLabWorkItem, LinearIssue, ProjectGroup, - Repo + Repo, + TuiAgent } from '../../../../shared/types' import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' +import { translate } from '@/i18n/i18n' const EMPTY_REPOS: Repo[] = [] @@ -114,3 +116,12 @@ export function toGitLabLinkedWorkItem(item: GitLabWorkItem): LinkedWorkItemSumm export function toLinearLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary { return buildLinearIssueLinkedWorkItem(issue) } + +export function getFolderWorkspacePrimaryActionLabel(quickAgent: TuiAgent | null): string { + return quickAgent + ? translate( + 'auto.components.sidebar.FolderWorkspaceComposerDialog.createStart', + 'Create & Start Agent' + ) + : translate('auto.components.sidebar.FolderWorkspaceComposerDialog.create', 'Create Workspace') +} diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-keyboard.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-keyboard.ts new file mode 100644 index 00000000000..b48c5ed7f01 --- /dev/null +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-keyboard.ts @@ -0,0 +1,60 @@ +import { useEffect } from 'react' +import type { RefObject } from 'react' +import { shouldAllowComposerEnterSubmitTarget } from '@/lib/new-workspace-enter-guard' +import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' + +type UseFolderWorkspaceComposerKeyboardInput = { + open: boolean + submitting: boolean + composerRef: RefObject + onOpenChange: (open: boolean) => void + onCreate: () => void +} + +export function useFolderWorkspaceComposerKeyboard({ + open, + submitting, + composerRef, + onOpenChange, + onCreate +}: UseFolderWorkspaceComposerKeyboardInput): void { + useEffect(() => { + if (!open) { + return + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Enter' && event.key !== 'Escape') { + return + } + const target = event.target + if (!(target instanceof HTMLElement)) { + return + } + if (event.key === 'Escape') { + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + target.isContentEditable + ) { + event.preventDefault() + target.blur() + return + } + event.preventDefault() + onOpenChange(false) + return + } + if (!isScreenSubmitShortcut(event)) { + return + } + if (!shouldAllowComposerEnterSubmitTarget(target, composerRef.current) || submitting) { + return + } + event.preventDefault() + onCreate() + } + window.addEventListener('keydown', onKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + }, [composerRef, onCreate, onOpenChange, open, submitting]) +} diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 76147302e10..e416b497e00 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -94,6 +94,10 @@ import { buildProjectHostSetupOptions, type ProjectHostSetupOption } from '@/lib/project-host-setup-options' +import { + buildNewWorkspaceProjectOptions, + type NewWorkspaceProjectOption +} from '@/lib/new-workspace-project-options' import { buildExecutionHostRegistry } from '../../../shared/execution-host-registry' import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides' import { queueNewWorkspaceTerminalFocus } from '@/lib/new-workspace-terminal-focus' @@ -160,8 +164,11 @@ export type UseComposerStateOptions = { export type ComposerCardProps = { eligibleRepos: ReturnType['repos'] repoId: string + projectOptions: NewWorkspaceProjectOption[] + selectedProjectId: string | null selectedRepoIsGit: boolean onRepoChange: (value: string) => void + onProjectChange: (value: string) => void projectHostSetupOptions: ProjectHostSetupOption[] selectedProjectHostSetupId: string | null onProjectHostSetupChange: (setupId: string) => void @@ -414,6 +421,15 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }), [eligibleRepos, hostOptions, projectHostSetups, selectedProjectId] ) + const projectOptions = useMemo( + () => + buildNewWorkspaceProjectOptions({ + projects, + projectHostSetups, + eligibleRepos + }), + [eligibleRepos, projectHostSetups, projects] + ) const selectedRepoSettings = useMemo(() => { if (!settings) { return settings @@ -1709,12 +1725,38 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }, [handleRepoChange, projectHostSetupOptions] ) + const handleProjectChange = useCallback( + (projectId: string): void => { + const preferredHostId = + selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.hostId : null + const nextRepoId = resolveWorkspaceCreationRepoId({ + eligibleRepos, + projects, + projectHostSetups, + projectId, + hostId: preferredHostId, + focusedHostScope: workspaceHostScope + }) + if (!nextRepoId) { + return + } + handleRepoChange(nextRepoId) + }, + [ + eligibleRepos, + handleRepoChange, + projectHostSetups, + projects, + selectedWorkspaceTarget, + workspaceHostScope + ] + ) const showProjectRequiredError = useCallback((): void => { setProjectError('Choose or add a project before creating a workspace.') requestAnimationFrame(() => { document .querySelector( - '[data-contextual-tour-target="workspace-creation-project"] [data-repo-combobox-root="true"][role="combobox"]' + '[data-contextual-tour-target="workspace-creation-project"] [data-project-combobox-root="true"][role="combobox"]' ) ?.focus() }) @@ -2626,8 +2668,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const cardProps: ComposerCardProps = { eligibleRepos, repoId, + projectOptions, + selectedProjectId, selectedRepoIsGit, onRepoChange: handleRepoChange, + onProjectChange: handleProjectChange, projectHostSetupOptions, selectedProjectHostSetupId, onProjectHostSetupChange: handleProjectHostSetupChange, diff --git a/src/renderer/src/lib/new-workspace-project-options.test.ts b/src/renderer/src/lib/new-workspace-project-options.test.ts new file mode 100644 index 00000000000..fc2990b0879 --- /dev/null +++ b/src/renderer/src/lib/new-workspace-project-options.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { buildNewWorkspaceProjectOptions } from './new-workspace-project-options' +import type { Project, ProjectHostSetup, Repo } from '../../../shared/types' + +function repo(id: string, overrides: Partial = {}): Repo { + return { + id, + path: `/tmp/${id}`, + displayName: id, + badgeColor: '#111111', + addedAt: 1, + upstream: { owner: 'stablyai', repo: 'orca' }, + ...overrides + } +} + +function project(overrides: Partial = {}): Project { + return { + id: 'github:stablyai/orca', + displayName: 'orca', + badgeColor: '#111111', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }, + sourceRepoIds: ['local-repo', 'ssh-repo'], + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function setup(overrides: Partial): ProjectHostSetup { + return { + id: overrides.id ?? 'local-setup', + projectId: overrides.projectId ?? 'github:stablyai/orca', + hostId: overrides.hostId ?? 'local', + repoId: overrides.repoId ?? 'local-repo', + path: overrides.path ?? '/tmp/orca', + displayName: overrides.displayName ?? 'orca', + setupState: overrides.setupState ?? 'ready', + setupMethod: overrides.setupMethod ?? 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('buildNewWorkspaceProjectOptions', () => { + it('deduplicates a logical project across local and SSH setups', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [project()], + projectHostSetups: [ + setup({ id: 'local-setup', hostId: 'local', repoId: 'local-repo' }), + setup({ id: 'ssh-setup', hostId: 'ssh:builder', repoId: 'ssh-repo' }) + ], + eligibleRepos: [repo('local-repo'), repo('ssh-repo', { connectionId: 'ssh:builder' })] + }) + + expect(options).toEqual([ + { + id: 'github:stablyai/orca', + displayName: 'orca', + badgeColor: '#111111', + detail: 'stablyai/orca' + } + ]) + }) + + it('excludes projects that do not have a ready eligible setup', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [project(), project({ id: 'repo:other', displayName: 'other' })], + projectHostSetups: [ + setup({ id: 'local-setup', repoId: 'local-repo' }), + setup({ + id: 'other-setup', + projectId: 'repo:other', + repoId: 'other-repo', + setupState: 'not-set-up' + }) + ], + eligibleRepos: [repo('local-repo'), repo('other-repo')] + }) + + expect(options.map((option) => option.id)).toEqual(['github:stablyai/orca']) + }) +}) diff --git a/src/renderer/src/lib/new-workspace-project-options.ts b/src/renderer/src/lib/new-workspace-project-options.ts new file mode 100644 index 00000000000..ebc3240d204 --- /dev/null +++ b/src/renderer/src/lib/new-workspace-project-options.ts @@ -0,0 +1,85 @@ +import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' +import type { Project, ProjectHostSetup, Repo } from '../../../shared/types' + +export type NewWorkspaceProjectOption = { + id: string + displayName: string + badgeColor: string + detail: string +} + +type BuildNewWorkspaceProjectOptionsInput = { + projects: readonly Project[] + projectHostSetups: readonly ProjectHostSetup[] + eligibleRepos: readonly Repo[] +} + +function getProjectModel({ + projects, + projectHostSetups, + eligibleRepos +}: BuildNewWorkspaceProjectOptionsInput): { + projects: readonly Project[] + projectHostSetups: readonly ProjectHostSetup[] +} { + if (projects.length > 0 || projectHostSetups.length > 0) { + return { projects, projectHostSetups } + } + const projection = projectHostSetupProjectionFromRepos(eligibleRepos) + return { + projects: projection.projects, + projectHostSetups: projection.setups + } +} + +function getProjectDetail(project: Project, readySetupCount: number): string { + if (project.providerIdentity) { + return `${project.providerIdentity.owner}/${project.providerIdentity.repo}` + } + if (readySetupCount > 1) { + return `${readySetupCount} hosts configured` + } + return 'Project' +} + +export function buildNewWorkspaceProjectOptions( + input: BuildNewWorkspaceProjectOptionsInput +): NewWorkspaceProjectOption[] { + const { eligibleRepos } = input + const { projects, projectHostSetups } = getProjectModel(input) + const eligibleRepoIds = new Set(eligibleRepos.map((repo) => repo.id)) + const readySetupCountsByProjectId = new Map() + + for (const setup of projectHostSetups) { + if (setup.setupState !== 'ready' || !eligibleRepoIds.has(setup.repoId)) { + continue + } + readySetupCountsByProjectId.set( + setup.projectId, + (readySetupCountsByProjectId.get(setup.projectId) ?? 0) + 1 + ) + } + + return projects + .filter((project) => (readySetupCountsByProjectId.get(project.id) ?? 0) > 0) + .map((project) => ({ + id: project.id, + displayName: project.displayName, + badgeColor: project.badgeColor, + detail: getProjectDetail(project, readySetupCountsByProjectId.get(project.id) ?? 0) + })) + .sort((a, b) => a.displayName.localeCompare(b.displayName) || a.detail.localeCompare(b.detail)) +} + +export function searchNewWorkspaceProjectOptions( + options: readonly NewWorkspaceProjectOption[], + rawQuery: string +): NewWorkspaceProjectOption[] { + const query = rawQuery.trim().toLowerCase() + if (!query) { + return [...options] + } + return options.filter((option) => + [option.displayName, option.detail].some((value) => value.toLowerCase().includes(query)) + ) +}