mirror of
https://github.com/stablyai/orca.git
synced 2026-10-15 16:02:49 +00:00
Fix multihost workspace composer project identity
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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.
|
||||
@@ -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<typeof RepoCombobox>['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({
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
<RepoCombobox
|
||||
repos={eligibleRepos}
|
||||
value={repoId}
|
||||
onValueChange={onRepoChange}
|
||||
<ProjectCombobox
|
||||
options={projectOptions}
|
||||
value={selectedProjectId}
|
||||
onValueChange={onProjectChange}
|
||||
onValueSelected={focusNameInput}
|
||||
placeholder={
|
||||
projectPlaceholder ??
|
||||
@@ -477,7 +486,6 @@ export default function NewWorkspaceComposerCard({
|
||||
// paints the familiar field ring instead of leaving no visible
|
||||
// focus state.
|
||||
triggerClassName="h-9 w-full border-input text-sm focus:border-ring focus:ring-[3px] focus:ring-ring/50"
|
||||
showStandaloneAddButton={false}
|
||||
invalid={Boolean(projectError)}
|
||||
describedBy={projectDescriptionId}
|
||||
/>
|
||||
|
||||
@@ -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 }) => <div>{children}</div>,
|
||||
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/command', () => ({
|
||||
Command: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CommandEmpty: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CommandInput: React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
(props, ref) => <input ref={ref} {...props} />
|
||||
),
|
||||
CommandList: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CommandItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
value
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onSelect?: (value: string) => void
|
||||
value: string
|
||||
}) => (
|
||||
<button type="button" data-command-value={value} onClick={() => onSelect?.(value)}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}))
|
||||
|
||||
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(
|
||||
<ProjectCombobox options={projects} value="github:stablyai/orca" onValueChange={vi.fn()} />
|
||||
)
|
||||
})
|
||||
|
||||
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(
|
||||
<ProjectCombobox
|
||||
options={projects}
|
||||
value="github:stablyai/orca"
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
)
|
||||
})
|
||||
act(() => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-command-value="github:stablyai/noqa"]')
|
||||
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith('github:stablyai/noqa')
|
||||
})
|
||||
})
|
||||
@@ -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<HTMLInputElement | null>(null)
|
||||
const focusFrameRef = React.useRef<number | null>(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<HTMLButtonElement>): 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 (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-invalid={invalid ? true : undefined}
|
||||
aria-describedby={describedBy}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
className={cn(
|
||||
'h-8 min-w-[184px] justify-between px-3 text-xs font-normal',
|
||||
triggerClassName
|
||||
)}
|
||||
data-project-combobox-root="true"
|
||||
>
|
||||
{selectedProject ? (
|
||||
<RepoBadgeLabel
|
||||
name={selectedProject.displayName}
|
||||
color={selectedProject.badgeColor}
|
||||
badgeClassName="size-1.5"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{placeholder}</span>
|
||||
)}
|
||||
<ChevronsUpDown className="size-3.5 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[var(--radix-popover-trigger-width)] min-w-[18rem] p-0"
|
||||
data-project-combobox-root="true"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
focusSearchInput()
|
||||
}}
|
||||
>
|
||||
<Command shouldFilter={false} value={commandValue} onValueChange={setCommandValue}>
|
||||
<CommandInput
|
||||
ref={setInputNode}
|
||||
placeholder={translate(
|
||||
'auto.components.new.workspace.ProjectCombobox.search',
|
||||
'Search projects...'
|
||||
)}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{translate(
|
||||
'auto.components.new.workspace.ProjectCombobox.empty',
|
||||
'No projects match your search.'
|
||||
)}
|
||||
</CommandEmpty>
|
||||
{filteredOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
onSelect={() => handleSelect(option.id)}
|
||||
className="items-center gap-2 px-3 py-2"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'size-4 text-foreground',
|
||||
option.id === value ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<RepoBadgeLabel
|
||||
name={option.displayName}
|
||||
color={option.badgeColor}
|
||||
className="max-w-full"
|
||||
/>
|
||||
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">
|
||||
{option.detail}
|
||||
</p>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<HTMLElement>(
|
||||
'[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'
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement | null>
|
||||
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])
|
||||
}
|
||||
@@ -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<typeof useAppStore.getState>['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<HTMLElement>(
|
||||
'[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,
|
||||
|
||||
@@ -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> = {}): Repo {
|
||||
return {
|
||||
id,
|
||||
path: `/tmp/${id}`,
|
||||
displayName: id,
|
||||
badgeColor: '#111111',
|
||||
addedAt: 1,
|
||||
upstream: { owner: 'stablyai', repo: 'orca' },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function project(overrides: Partial<Project> = {}): 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>): 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'])
|
||||
})
|
||||
})
|
||||
@@ -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<string, number>()
|
||||
|
||||
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))
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user