Fix multihost workspace composer project identity

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-12 01:03:55 -07:00
co-authored by Orca
parent 720a1d4ea5
commit e5301ba6d8
11 changed files with 723 additions and 62 deletions
@@ -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])
}
+46 -1
View File
@@ -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))
)
}