Show unavailable hosts in workspace run target

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-11 12:33:58 -07:00
co-authored by Orca
parent 9a9d00af9f
commit e0ef859772
7 changed files with 157 additions and 20 deletions
@@ -314,7 +314,10 @@ Needs:
- compatibility resolver from `{ projectId, hostId }` to current repo/setup
internals while old APIs remain
Current branch status: partially implemented.
Current branch status: partially implemented. Ready project-host setups can be
selected from the composer `Run on` menu, and known hosts that still need setup
now appear as disabled rows instead of being invisible. Inline setup actions for
those rows are still missing.
### 5. Project Setup Flow
@@ -471,10 +474,13 @@ Already partially landed:
- CLI commands for listing projects/setups and creating worktrees by
project/setup
- composer `Run on` selector when a project has multiple ready setups
- disabled `Run on` rows for known hosts where the selected project is not set
up yet
Not complete yet:
- clone/provision flows
- inline setup actions from the composer `Run on` menu
- bulk setup flows and setup for hosts that are not already known to the client
- project settings split into global and host-specific ownership
- host settings/capability UI aligned with project setup
+7 -1
View File
@@ -354,7 +354,10 @@ Needs:
- compatibility resolver from `{ projectId, hostId }` to current repo/setup
internals while old APIs remain
Current branch status: partially implemented.
Current branch status: partially implemented. Ready project-host setups can be
selected from the composer `Run on` menu, and known hosts that still need setup
now appear as disabled rows instead of being invisible. Inline setup actions for
those rows are still missing.
### 5. Project Setup Flow
@@ -510,10 +513,13 @@ Already partially landed:
- CLI commands for listing projects/setups and creating worktrees by
project/setup
- composer `Run on` selector when a project has multiple ready setups
- disabled `Run on` rows for known hosts where the selected project is not set
up yet
Not complete yet:
- clone/provision flows
- inline setup actions from the composer `Run on` menu
- bulk setup flows and setup for hosts that are not already known to the client
- full project settings split into global and host-specific ownership
- host settings/capability UI aligned with project setup
+6 -2
View File
@@ -743,6 +743,10 @@ Landed so far:
panes, and imports an existing folder on another known local, SSH, or active
runtime host. Setup routing now follows the selected setup host instead of the
currently focused runtime.
- Updated the new workspace composer `Run on` menu so known hosts can appear as
disabled "project not set up on this host" rows, not only as invisible missing
options. Ready setups remain selectable and still resolve through the
compatibility repo path.
- Added tests for local repos, SSH repos, same-provider multi-host grouping,
no-identity same-name non-grouping, selector cache behavior, persistence
backfill, repo mutation synchronization, renderer hydration, and runtime RPC
@@ -764,8 +768,8 @@ Important limitation:
Remaining end-to-end work:
- teach workspace creation to resolve `{ projectId, hostId }` through a ready
project-host setup
- add inline clone/import actions for `Run on` hosts where the project is not
set up yet
- broaden setup-on-host flows beyond known local, SSH, and active runtime hosts
- add clone/provision setup-on-host flows
- split settings into explicit client, host, project, and project-host setup
@@ -19,14 +19,18 @@ export default function ProjectHostSetupCombobox({
onValueChange
}: ProjectHostSetupComboboxProps): React.JSX.Element {
const [open, setOpen] = React.useState(false)
const selected = options.find((option) => option.id === value) ?? options[0] ?? null
const readyOptions = options.filter((option) => option.kind === 'ready')
const selected = readyOptions.find((option) => option.id === value) ?? readyOptions[0] ?? null
const handleSelect = React.useCallback(
(setupId: string): void => {
if (!readyOptions.some((option) => option.id === setupId)) {
return
}
onValueChange(setupId)
setOpen(false)
},
[onValueChange]
[onValueChange, readyOptions]
)
return (
@@ -72,6 +76,7 @@ export default function ProjectHostSetupCombobox({
key={option.id}
value={option.id}
onSelect={() => handleSelect(option.id)}
disabled={option.kind !== 'ready'}
className="items-center gap-2 px-3 py-2"
>
<Check
@@ -84,7 +89,7 @@ export default function ProjectHostSetupCombobox({
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{option.label}</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{option.path}
{option.kind === 'ready' ? option.path : option.detail}
</div>
</div>
</CommandItem>
+20 -3
View File
@@ -88,6 +88,8 @@ import {
buildProjectHostSetupOptions,
type ProjectHostSetupOption
} from '@/lib/project-host-setup-options'
import { buildExecutionHostRegistry } from '../../../shared/execution-host-registry'
import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides'
import { queueNewWorkspaceTerminalFocus } from '@/lib/new-workspace-terminal-focus'
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions'
@@ -331,7 +333,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const sparsePresetsByRepo = useAppStore((s) => s.sparsePresetsByRepo)
const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration)
const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId)
const workspaceHostScope = useAppStore((s) => s.workspaceHostScope)
const eligibleRepos = useMemo(() => getComposerEligibleRepos(repos), [repos])
const draftRepoId = persistDraft ? (newWorkspaceDraft?.repoId ?? null) : null
@@ -382,14 +386,27 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
selectedWorkspaceTarget.status === 'ready'
? selectedWorkspaceTarget.target.projectHostSetupId
: null
const hostOptions = useMemo(
() =>
buildExecutionHostRegistry({
repos,
settings,
sshTargetLabels,
sshConnectionStates,
runtimeStatusByEnvironmentId,
hostLabelOverrides: getHostDisplayLabelOverrides(settings)
}),
[repos, settings, sshConnectionStates, sshTargetLabels, runtimeStatusByEnvironmentId]
)
const projectHostSetupOptions = useMemo(
() =>
buildProjectHostSetupOptions({
projectId: selectedProjectId,
projectHostSetups,
eligibleRepos
eligibleRepos,
hosts: hostOptions
}),
[eligibleRepos, projectHostSetups, selectedProjectId]
[eligibleRepos, hostOptions, projectHostSetups, selectedProjectId]
)
const selectedRepoSettings = useMemo(() => {
if (!settings) {
@@ -1679,7 +1696,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const handleProjectHostSetupChange = useCallback(
(setupId: string): void => {
const option = projectHostSetupOptions.find((candidate) => candidate.id === setupId)
if (!option) {
if (!option || option.kind !== 'ready') {
return
}
handleRepoChange(option.repoId)
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { ExecutionHostId } from '../../../shared/execution-host'
import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry'
import type { ProjectHostSetup, Repo } from '../../../shared/types'
import { buildProjectHostSetupOptions } from './project-host-setup-options'
@@ -35,6 +36,20 @@ function setup(
}
}
function host(
id: ExecutionHostId,
overrides: Partial<ExecutionHostRegistryEntry> = {}
): ExecutionHostRegistryEntry {
return {
id,
kind: id === 'local' ? 'local' : id.startsWith('ssh:') ? 'ssh' : 'runtime',
label: id === 'local' ? 'Local Mac' : id.replace(/^ssh:|^runtime:/, ''),
detail: id === 'local' ? 'This computer' : 'Host',
health: id === 'local' ? 'local' : 'available',
...overrides
}
}
describe('buildProjectHostSetupOptions', () => {
it('returns ready setup choices for one project sorted with local first', () => {
const options = buildProjectHostSetupOptions({
@@ -66,4 +81,23 @@ describe('buildProjectHostSetupOptions', () => {
expect(options.map((option) => option.id)).toEqual(['ready'])
})
it('includes known hosts that still need project setup', () => {
const options = buildProjectHostSetupOptions({
projectId: 'project-1',
eligibleRepos: [repo('local-repo')],
hosts: [host('local'), host('ssh:builder', { label: 'Builder' })],
projectHostSetups: [setup('local', 'project-1', 'local', 'local-repo')]
})
expect(options).toEqual([
expect.objectContaining({ id: 'local', kind: 'ready', label: 'Local Mac' }),
expect.objectContaining({
id: 'needs-setup:ssh:builder',
kind: 'needs-setup',
label: 'Builder',
detail: 'Project not set up on this host'
})
])
})
})
@@ -3,32 +3,78 @@ import {
LOCAL_EXECUTION_HOST_ID,
type ExecutionHostId
} from '../../../shared/execution-host'
import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry'
import type { ProjectHostSetup, Repo } from '../../../shared/types'
export type ProjectHostSetupOption = {
id: string
export type ProjectHostSetupOption =
| {
id: string
kind: 'ready'
projectId: string
hostId: ExecutionHostId
repoId: string
label: string
detail: string
path: string
}
| {
id: string
kind: 'needs-setup'
projectId: string
hostId: ExecutionHostId
label: string
detail: string
}
export type ReadyProjectHostSetupOption = Extract<ProjectHostSetupOption, { kind: 'ready' }>
type NeedsSetupProjectHostOption = Extract<ProjectHostSetupOption, { kind: 'needs-setup' }>
type BuildReadySetupOptionsInput = {
projectId: string
hostId: ExecutionHostId
repoId: string
label: string
detail: string
path: string
projectHostSetups: readonly ProjectHostSetup[]
eligibleRepos: readonly Repo[]
}
type BuildNeedsSetupOptionsInput = {
projectId: string
hosts: readonly ExecutionHostRegistryEntry[]
readySetupByHost: ReadonlyMap<ExecutionHostId, ReadyProjectHostSetupOption>
}
type BuildProjectHostSetupOptionsInput = {
projectId: string | null
projectHostSetups: readonly ProjectHostSetup[]
eligibleRepos: readonly Repo[]
hosts?: readonly ExecutionHostRegistryEntry[]
}
export function buildProjectHostSetupOptions({
projectId,
projectHostSetups,
eligibleRepos
eligibleRepos,
hosts = []
}: BuildProjectHostSetupOptionsInput): ProjectHostSetupOption[] {
if (!projectId) {
return []
}
const readyOptions = buildReadySetupOptions({ projectId, projectHostSetups, eligibleRepos })
const readySetupByHost = new Map(readyOptions.map((option) => [option.hostId, option]))
return [
...readyOptions,
...buildNeedsSetupOptions({
projectId,
hosts,
readySetupByHost
})
].sort((a, b) => compareProjectHostSetupOptions(a, b))
}
function buildReadySetupOptions({
projectId,
projectHostSetups,
eligibleRepos
}: BuildReadySetupOptionsInput): ReadyProjectHostSetupOption[] {
const eligibleRepoIds = new Set(eligibleRepos.map((repo) => repo.id))
return projectHostSetups
.filter(
@@ -39,6 +85,7 @@ export function buildProjectHostSetupOptions({
)
.map((setup) => ({
id: setup.id,
kind: 'ready' as const,
projectId: setup.projectId,
hostId: setup.hostId,
repoId: setup.repoId,
@@ -46,7 +93,23 @@ export function buildProjectHostSetupOptions({
detail: setup.displayName,
path: setup.path
}))
.sort((a, b) => compareProjectHostSetupOptions(a, b))
}
function buildNeedsSetupOptions({
projectId,
hosts,
readySetupByHost
}: BuildNeedsSetupOptionsInput): NeedsSetupProjectHostOption[] {
return hosts
.filter((host) => !readySetupByHost.has(host.id))
.map((host) => ({
id: `needs-setup:${host.id}`,
kind: 'needs-setup' as const,
projectId,
hostId: host.id,
label: host.label || getExecutionHostLabel(host.id),
detail: 'Project not set up on this host'
}))
}
function compareProjectHostSetupOptions(
@@ -59,5 +122,7 @@ function compareProjectHostSetupOptions(
if (b.hostId === LOCAL_EXECUTION_HOST_ID && a.hostId !== LOCAL_EXECUTION_HOST_ID) {
return 1
}
return a.label.localeCompare(b.label) || a.path.localeCompare(b.path)
const aDetail = a.kind === 'ready' ? a.path : a.detail
const bDetail = b.kind === 'ready' ? b.path : b.detail
return a.label.localeCompare(b.label) || aDetail.localeCompare(bDetail)
}