diff --git a/notes/project-first-host-model-change-inventory.md b/notes/project-first-host-model-change-inventory.md index 89b6787c57c..9a9fb7222b1 100644 --- a/notes/project-first-host-model-change-inventory.md +++ b/notes/project-first-host-model-change-inventory.md @@ -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 diff --git a/notes/project-first-host-model-discussion.md b/notes/project-first-host-model-discussion.md index d9d9f8a6406..deb9cfa023e 100644 --- a/notes/project-first-host-model-discussion.md +++ b/notes/project-first-host-model-discussion.md @@ -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 diff --git a/notes/project-first-host-model.md b/notes/project-first-host-model.md index 770f11357bb..d054b85fb97 100644 --- a/notes/project-first-host-model.md +++ b/notes/project-first-host-model.md @@ -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 diff --git a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx index 726d803e695..02e27e98b7c 100644 --- a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx +++ b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx @@ -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" >
{option.label}
- {option.path} + {option.kind === 'ready' ? option.path : option.detail}
diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 18de5ebf21e..7de35ab5f07 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -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) diff --git a/src/renderer/src/lib/project-host-setup-options.test.ts b/src/renderer/src/lib/project-host-setup-options.test.ts index 72c454b31d6..fa4c7d747a5 100644 --- a/src/renderer/src/lib/project-host-setup-options.test.ts +++ b/src/renderer/src/lib/project-host-setup-options.test.ts @@ -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 { + 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' + }) + ]) + }) }) diff --git a/src/renderer/src/lib/project-host-setup-options.ts b/src/renderer/src/lib/project-host-setup-options.ts index bbbd33dc19a..f4f06929656 100644 --- a/src/renderer/src/lib/project-host-setup-options.ts +++ b/src/renderer/src/lib/project-host-setup-options.ts @@ -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 + +type NeedsSetupProjectHostOption = Extract + +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 } 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) }