fix(new-workspace): list each same-host checkout in the create-worktree picker (#36180)

* fix(new-workspace): list each same-host checkout of a project in the create-worktree picker

Two local clones of one Git remote share a project id. The sidebar already
shows each clone as its own section, but the create-worktree dialog listed
the project once, its Run on picker kept only the first clone per host, and
an explicit setup id was folded onto that first clone. The second clone was
unreachable, and starting a worktree from its sidebar section created it in
the first clone instead.

v1.4.243 (#32272) split such clones into separate projects, which hid this;
its revert in v1.4.244 (#34662) brought the single picker row back.

- Project picker: add one row per same-host checkout, using the sidebar's
  own checkout-scoped rule; the project row keeps the project's other hosts.
- Run on: one row per host still, but the selected checkout is the one shown.
- Target resolution: only setups for the same checkout path collapse onto
  the first one; a distinct checkout on the same host stays the target.

* fix(new-workspace): name the host on remote checkout rows in the project picker

Remote same-host checkouts were labeled by path only, so a checkout on an
SSH/runtime host could read identically to a local one with the same repo
name and path. Prefix the host label for non-local checkout rows and cover
checkout-row listing, selection and labeling in a focused lib test.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
OrcaWin
2026-11-02 22:03:33 -08:00
committed by GitHub
co-authored by m4air Neil
parent 971e575215
commit e4804c76df
13 changed files with 493 additions and 41 deletions
@@ -6,6 +6,7 @@ import ProjectCombobox from '@/components/new-workspace/ProjectCombobox'
import RunTargetCombobox from '@/components/new-workspace/RunTargetCombobox'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { getSelectedNewWorkspaceProjectOptionId } from '@/lib/new-workspace-project-options'
import type {
EphemeralVmRecipeOption,
NeedsProjectHostOption,
@@ -119,7 +120,12 @@ export function NewWorkspaceComposerProjectSection({
<div className="space-y-1" data-contextual-tour-target="workspace-creation-project">
<ProjectCombobox
options={projectOptions}
value={selectedProjectId}
value={getSelectedNewWorkspaceProjectOptionId(
projectOptions,
selectedProjectId,
selectedProjectHostSetupId,
selectedRepoExecutionHostId
)}
onValueChange={onProjectChange}
onValueSelected={focusNameInput}
onAddProject={onAddProject}
@@ -161,9 +161,12 @@ export function useReviewBaseSelectionFixture(initialReview?: GitHubWorkItem) {
actionableHostIds: new Set([LOCAL_EXECUTION_HOST_ID]),
projectGroups: [folderGroup],
projectHostSetups: [],
projectOptions: [],
projects: [],
repos: [repo, replacementRepo],
setRepoId,
setSelectedProjectHostSetupOverrideId: vi.fn(),
setSelectedProjectHostSetupOverrideHostId: vi.fn(),
selectedWorkspaceTarget: { status: 'unavailable', reason: 'no-eligible-repo' },
workspaceHostScope: LOCAL_EXECUTION_HOST_ID
})
@@ -0,0 +1,148 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type { Repo } from '../../../../shared/repo-types'
import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../../shared/worktree/retired-name-registry'
import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection'
import { getRepoWorkspaceCreationTargetHint } from '@/lib/project-host-workspace-target'
import * as decisions from './composer-decisions'
import { useComposerTargetState } from './composer-target-state'
import { useComposerExternalSync } from './composer-external-sync'
import { useComposerSourceState } from './composer-source-state'
const originalState = useAppStore.getState()
let originalApi: PropertyDescriptor | undefined
const remote = {
canonicalKey: 'github.com/stablyai/orca',
remoteName: 'origin',
remoteUrl: 'git@github.com:stablyai/orca.git'
}
const mainClone: Repo = {
id: 'main-clone',
path: '/code/orca',
displayName: 'orca',
badgeColor: '',
addedAt: 0,
gitRemoteIdentity: remote
}
const reviewClone: Repo = {
...mainClone,
id: 'review-clone',
path: '/code/orca-review',
displayName: 'orca-review',
addedAt: 1
}
beforeEach(() => {
originalApi = Object.getOwnPropertyDescriptor(window, 'api')
Object.defineProperty(window, 'api', {
configurable: true,
value: {
preflight: {
detectAgents: vi.fn<Window['api']['preflight']['detectAgents']>().mockResolvedValue([])
},
gh: {
repoSlug: vi.fn<Window['api']['gh']['repoSlug']>().mockResolvedValue(null)
},
worktrees: {
listRetiredNames: vi
.fn<Window['api']['worktrees']['listRetiredNames']>()
.mockResolvedValue(EMPTY_RETIRED_NAME_REGISTRY)
}
}
})
})
afterEach(() => {
cleanup()
useAppStore.setState(originalState, true)
if (originalApi) {
Object.defineProperty(window, 'api', originalApi)
} else {
Reflect.deleteProperty(window, 'api')
}
})
function renderComposerFor(repo: Repo) {
const repos = [mainClone, reviewClone]
const projection = projectHostSetupProjectionFromRepos(repos)
useAppStore.setState({
settings: null,
repos,
projects: [...projection.projects],
projectHostSetups: [...projection.setups],
projectGroups: [],
newWorkspaceDraft: null,
worktreesByRepo: {},
sparsePresetsByRepo: {},
workspaceHostScope: 'all',
detectedAgentIds: []
})
return renderHook(() => {
const target = useComposerTargetState(
{
persistDraft: false,
initialRepoId: repo.id,
initialWorkspaceTarget: getRepoWorkspaceCreationTargetHint(repo, projection.setups)
},
decisions
)
const external = useComposerExternalSync(target)
const source = useComposerSourceState(target, external)
return { ...target, source }
})
}
function checkoutOptionId(
options: ReturnType<
typeof renderComposerFor
>['result']['current']['runtimeTargetSelection']['projectOptions'],
repoId: string
): string {
const option = options.find(
(entry) => entry.kind === 'project' && entry.checkout?.repoId === repoId
)
if (!option) {
throw new Error(`Expected a picker row for ${repoId}`)
}
return option.id
}
describe('same-host checkouts of one remote', () => {
it('opens on the checkout it was started from and lists both checkouts', () => {
const { result } = renderComposerFor(reviewClone)
const selection = result.current.runtimeTargetSelection
expect(selection.selectedRepo).toBe(reviewClone)
expect(selection.projectOptions.map((option) => option.displayName)).toEqual([
'orca',
'orca-review'
])
expect(selection.projectHostSetupOptions).toMatchObject([
{ kind: 'ready', repoId: reviewClone.id, path: reviewClone.path }
])
})
it('switches between the checkouts from the project picker', () => {
const { result } = renderComposerFor(mainClone)
act(() =>
result.current.source.projectTargetActions.handleProjectChange(
checkoutOptionId(result.current.runtimeTargetSelection.projectOptions, reviewClone.id)
)
)
expect(result.current.runtimeTargetSelection.selectedRepo).toBe(reviewClone)
expect(result.current.runtimeTargetSelection.projectHostSetupOptions).toMatchObject([
{ kind: 'ready', repoId: reviewClone.id }
])
act(() =>
result.current.source.projectTargetActions.handleProjectChange(
checkoutOptionId(result.current.runtimeTargetSelection.projectOptions, mainClone.id)
)
)
expect(result.current.runtimeTargetSelection.selectedRepo).toBe(mainClone)
})
})
@@ -96,6 +96,7 @@ export function useComposerSourceState(
linkedWorkItem: target.sourceContextState.linkedWorkItem,
projectGroups: target.composerTargetStore.projectGroups,
projectHostSetups: target.composerTargetStore.projectHostSetups,
projectOptions: target.runtimeTargetSelection.projectOptions,
projects: target.composerTargetStore.projects,
repos: target.composerTargetStore.repos,
setBaseBranch: target.workspaceIdentityState.setBaseBranch,
@@ -115,6 +116,10 @@ export function useComposerSourceState(
setReuseEligibleBranch: target.workspaceIdentityState.setReuseEligibleBranch,
setReuseSelectedBranch: target.workspaceIdentityState.setReuseSelectedBranch,
setSelectedProjectGroupId: target.initialTargetState.setSelectedProjectGroupId,
setSelectedProjectHostSetupOverrideHostId:
target.initialTargetState.setSelectedProjectHostSetupOverrideHostId,
setSelectedProjectHostSetupOverrideId:
target.initialTargetState.setSelectedProjectHostSetupOverrideId,
setSparseDirectories: target.asyncComposerState.setSparseDirectories,
setSparseEnabled: target.asyncComposerState.setSparseEnabled,
setSparseSelectedPresetId: target.asyncComposerState.setSparseSelectedPresetId,
@@ -158,7 +158,7 @@ function renderUnavailableMappedComposer(
includeSetupHint?: boolean
emptyRepoOverride?: boolean
missingSshSetup?: boolean
canonicalSameHostSetup?: boolean
sameHostSiblingCheckout?: boolean
} = {}
) {
const mappedRepo: Repo = {
@@ -166,11 +166,11 @@ function renderUnavailableMappedComposer(
id: options.repeatedId ? currentRepo.id : 'mapped',
path: '/mapped',
connectionId: 'builder',
...(options.canonicalSameHostSetup ? { upstream: { owner: 'fixture', repo: 'app' } } : {})
...(options.sameHostSiblingCheckout ? { upstream: { owner: 'fixture', repo: 'app' } } : {})
}
const canonicalRepo: Repo = { ...mappedRepo, id: 'canonical', path: '/canonical' }
const repos = options.canonicalSameHostSetup
? [currentRepo, canonicalRepo, mappedRepo]
const siblingRepo: Repo = { ...mappedRepo, id: 'sibling', path: '/sibling' }
const repos = options.sameHostSiblingCheckout
? [currentRepo, siblingRepo, mappedRepo]
: [currentRepo, mappedRepo]
const projection = projectHostSetupProjectionFromRepos(repos)
const initialWorkspaceTarget: WorkspaceCreationTargetHint = {
@@ -216,7 +216,7 @@ function renderUnavailableMappedComposer(
const source = useComposerSourceState(target, external)
return { ...target, source }
})
return { ...hook, mappedRepo, canonicalRepo }
return { ...hook, mappedRepo }
}
describe('unavailable mapped Jira destinations', () => {
@@ -299,20 +299,20 @@ describe('unavailable mapped Jira destinations', () => {
expect(result.current.initialTargetState.repoId).toBe(currentRepo.id)
})
it('aligns the repo identity with the canonical same-host setup after recovery', () => {
const { result, canonicalRepo } = renderUnavailableMappedComposer({
canonicalSameHostSetup: true
it('keeps the mapped checkout, not an older same-host sibling, after recovery', () => {
const { result, mappedRepo } = renderUnavailableMappedComposer({
sameHostSiblingCheckout: true
})
expect(result.current.runtimeTargetSelection.selectedWorkspaceTarget.status).toBe('unavailable')
act(() => useAppStore.setState({ sshTargetLabels: new Map([['builder', 'Builder']]) }))
expect(result.current.runtimeTargetSelection.selectedWorkspaceTarget).toMatchObject({
status: 'ready',
target: { repo: canonicalRepo, hostId: 'ssh:builder' }
target: { repo: mappedRepo, hostId: 'ssh:builder' }
})
expect(result.current.runtimeTargetSelection.selectedRepo).toBe(canonicalRepo)
expect(result.current.initialTargetState.repoId).toBe(canonicalRepo.id)
expect(result.current.runtimeTargetSelection.repoIdRef.current).toBe(canonicalRepo.id)
expect(result.current.runtimeTargetSelection.selectedRepo).toBe(mappedRepo)
expect(result.current.initialTargetState.repoId).toBe(mappedRepo.id)
expect(result.current.runtimeTargetSelection.repoIdRef.current).toBe(mappedRepo.id)
expect(result.current.sourceContextState.taskSourceContext).toEqual(jiraSource)
expect(result.current.sourceContextState.linkedWorkItem?.jiraIdentifier).toBe('APP-1')
})
@@ -13,6 +13,7 @@ type ProjectTargetActionsInput = Pick<
| 'linkedWorkItem'
| 'projectGroups'
| 'projectHostSetups'
| 'projectOptions'
| 'projects'
| 'repos'
| 'setBaseBranch'
@@ -31,6 +32,8 @@ type ProjectTargetActionsInput = Pick<
| 'setReuseEligibleBranch'
| 'setReuseSelectedBranch'
| 'setSelectedProjectGroupId'
| 'setSelectedProjectHostSetupOverrideHostId'
| 'setSelectedProjectHostSetupOverrideId'
| 'setSparseDirectories'
| 'setSparseEnabled'
| 'setSparseSelectedPresetId'
@@ -44,6 +47,7 @@ import { useCallback } from 'react'
import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../../shared/new-workspace/workspace-source'
import {
getProjectGroupIdFromNewWorkspaceOptionId,
getProjectRowHostSetups,
findActionableFolderProjectGroup
} from '@/lib/new-workspace-project-options'
import { translate } from '@/i18n/i18n'
@@ -60,6 +64,7 @@ export function useProjectTargetActions(input: ProjectTargetActionsInput) {
linkedWorkItem,
projectGroups,
projectHostSetups,
projectOptions,
projects,
repos,
setBaseBranch,
@@ -78,6 +83,8 @@ export function useProjectTargetActions(input: ProjectTargetActionsInput) {
setReuseEligibleBranch,
setReuseSelectedBranch,
setSelectedProjectGroupId,
setSelectedProjectHostSetupOverrideHostId,
setSelectedProjectHostSetupOverrideId,
setSparseDirectories,
setSparseEnabled,
setSparseSelectedPresetId,
@@ -188,14 +195,23 @@ export function useProjectTargetActions(input: ProjectTargetActionsInput) {
}
setSelectedProjectGroupId(null)
const option = projectOptions.find((candidate) => candidate.id === projectId)
const checkout = option?.kind === 'project' ? option.checkout : undefined
if (checkout) {
handleRepoChange(checkout.repoId, { forceResetStartFrom: isProjectGroupTarget })
// After handleRepoChange, which clears the override; a same-host sibling checkout needs the exact setup.
setSelectedProjectHostSetupOverrideId(checkout.projectHostSetupId)
setSelectedProjectHostSetupOverrideHostId(checkout.hostId)
return
}
const preferredHostId =
selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.hostId : null
// Prefer focusedHostScope rather than hard-pin hostId, so projects available only on another host remain selectable.
const nextRepoId = resolveWorkspaceCreationRepoId({
eligibleRepos,
projects,
projectHostSetups,
projectId,
projectHostSetups: getProjectRowHostSetups(projectOptions, projectHostSetups),
projectId: option?.kind === 'project' ? option.projectId : projectId,
focusedHostScope: preferredHostId ?? workspaceHostScope,
actionableHostIds
})
@@ -212,12 +228,15 @@ export function useProjectTargetActions(input: ProjectTargetActionsInput) {
isProjectGroupTarget,
projectGroups,
projectHostSetups,
projectOptions,
projects,
repos,
selectedWorkspaceTarget,
workspaceHostScope,
initialProjectGroupAppliedRef,
setSelectedProjectGroupId,
setSelectedProjectHostSetupOverrideHostId,
setSelectedProjectHostSetupOverrideId,
setProjectError
]
)
@@ -214,9 +214,11 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe
projectId: selectedRepoProjectId,
projectHostSetups,
eligibleRepos,
hosts: hostOptions
hosts: hostOptions,
selectedSetup:
selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target : null
}),
[eligibleRepos, hostOptions, projectHostSetups, selectedRepoProjectId]
[eligibleRepos, hostOptions, projectHostSetups, selectedRepoProjectId, selectedWorkspaceTarget]
)
const projectOptions = useMemo(
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import {
buildNewWorkspaceProjectOptions,
getProjectRowHostSetups,
getSelectedNewWorkspaceProjectOptionId
} from './new-workspace-project-options'
import type { Project, ProjectHostSetup } from '../../../shared/project-types'
import type { Repo } from '../../../shared/repo-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
}
}
const project: Project = {
id: 'github:stablyai/orca',
displayName: 'orca',
badgeColor: '#111111',
providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' },
sourceRepoIds: [],
createdAt: 1,
updatedAt: 1
}
function setup(
overrides: Pick<ProjectHostSetup, 'id' | 'repoId'> & Partial<ProjectHostSetup>
): ProjectHostSetup {
return {
projectId: project.id,
hostId: 'local',
path: `/code/${overrides.repoId}`,
displayName: overrides.repoId,
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1,
...overrides
}
}
describe('new-workspace project checkout rows', () => {
it('lists each same-host checkout and keeps the project row for its other hosts', () => {
const projectHostSetups = [
setup({ id: 'main-setup', repoId: 'main', path: '/code/orca' }),
setup({ id: 'review-setup', repoId: 'review', path: '/code/orca-review' }),
setup({ id: 'ssh-setup', hostId: 'ssh:builder', repoId: 'ssh-repo' })
]
const options = buildNewWorkspaceProjectOptions({
projects: [project],
projectHostSetups,
eligibleRepos: [
repo('main', { displayName: 'orca' }),
repo('review', { displayName: 'orca-review' }),
repo('ssh-repo', { connectionId: 'builder' })
]
})
expect(options.map((option) => [option.displayName, option.detail])).toEqual([
['orca', '/code/orca'],
['orca', 'stablyai/orca'],
['orca-review', '/code/orca-review']
])
expect(getProjectRowHostSetups(options, projectHostSetups).map((entry) => entry.id)).toEqual([
'ssh-setup'
])
expect(
getSelectedNewWorkspaceProjectOptionId(options, project.id, 'review-setup', 'local')
).toBe(options[2].id)
expect(
getSelectedNewWorkspaceProjectOptionId(options, project.id, 'ssh-setup', 'ssh:builder')
).toBe(project.id)
})
it('names the host on a remote checkout so it does not read as the local one', () => {
// Same repo names, paths and setup ids on both machines; without the host the rows are identical.
const projectHostSetups = [
setup({ id: 'a', repoId: 'local-a', path: '/code/orca' }),
setup({ id: 'b', repoId: 'local-b', path: '/code/orca-b' }),
setup({ id: 'a', hostId: 'ssh:builder', repoId: 'ssh-a', path: '/code/orca' }),
setup({ id: 'b', hostId: 'ssh:builder', repoId: 'ssh-b', path: '/code/orca-b' })
]
const options = buildNewWorkspaceProjectOptions({
projects: [project],
projectHostSetups,
eligibleRepos: [
repo('local-a', { displayName: 'orca' }),
repo('local-b', { displayName: 'orca-b' }),
repo('ssh-a', { displayName: 'orca', connectionId: 'builder' }),
repo('ssh-b', { displayName: 'orca-b', connectionId: 'builder' })
],
hosts: [
{ id: 'local', label: 'This Mac' },
{ id: 'ssh:builder', label: 'Builder' }
]
})
expect(options.map((option) => [option.displayName, option.detail])).toEqual([
['orca', '/code/orca'],
['orca', 'Builder · /code/orca'],
['orca-b', '/code/orca-b'],
['orca-b', 'Builder · /code/orca-b']
])
expect(new Set(options.map((option) => option.id)).size).toBe(4)
expect(getSelectedNewWorkspaceProjectOptionId(options, project.id, 'a', 'ssh:builder')).toBe(
options[1].id
)
})
})
@@ -4,13 +4,30 @@ import type { Project, ProjectHostSetup } from '../../../shared/project-types'
import type { Repo } from '../../../shared/repo-types'
import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text'
import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
import {
getExecutionHostLabel,
getRepoExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
type ExecutionHostId
} from '../../../shared/execution-host'
import {
buildProjectGroupingIndex,
isCheckoutScopedProjectSetup
} from '../../../shared/project-section-grouping'
import {
getDuplicateProjectDetailsById,
type ProjectSetupDirectory
} from './new-workspace-duplicate-project-details'
export const NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX = 'project-group:'
const NEW_WORKSPACE_PROJECT_CHECKOUT_OPTION_PREFIX = 'project-checkout:'
/** One of several same-host checkouts of a project; the sidebar shows each as its own section. */
export type NewWorkspaceProjectCheckout = {
projectHostSetupId: string
hostId: ExecutionHostId
repoId: string
}
export type NewWorkspaceProjectOption =
| {
@@ -20,6 +37,7 @@ export type NewWorkspaceProjectOption =
displayName: string
badgeColor: string
detail: string
checkout?: NewWorkspaceProjectCheckout
}
| {
kind: 'project-group'
@@ -85,20 +103,27 @@ export function buildNewWorkspaceProjectOptions(
): NewWorkspaceProjectOption[] {
const { eligibleRepos } = input
const { projects, projectHostSetups } = getProjectModel(input)
const eligibleRepoIds = new Set(eligibleRepos.map((repo) => repo.id))
const eligibleRepoById = new Map(eligibleRepos.map((repo) => [repo.id, repo]))
const hostLabelById = new Map((input.hosts ?? []).map((host) => [host.id, host.label]))
const liveHostIds = input.hosts ? new Set(input.hosts.map((host) => host.id)) : null
const groupingIndex = buildProjectGroupingIndex({ projects, projectHostSetups })
const readySetupCountsByProjectId = new Map<string, number>()
const setupDirectoriesByProjectId = new Map<string, ProjectSetupDirectory[]>()
const checkoutSetups: ProjectHostSetup[] = []
for (const setup of projectHostSetups) {
if (
setup.setupState !== 'ready' ||
!eligibleRepoIds.has(setup.repoId) ||
!eligibleRepoById.has(setup.repoId) ||
(liveHostIds && !liveHostIds.has(setup.hostId))
) {
continue
}
// Why: same-host checkouts of one remote share a project id; each stays selectable, as in the sidebar.
if (groupingIndex && isCheckoutScopedProjectSetup(setup, groupingIndex)) {
checkoutSetups.push(setup)
continue
}
readySetupCountsByProjectId.set(
setup.projectId,
(readySetupCountsByProjectId.get(setup.projectId) ?? 0) + 1
@@ -126,12 +151,86 @@ export function buildNewWorkspaceProjectOptions(
hostLabelById
)
return options
.map(({ detailSource: _detailSource, ...option }) => {
const projectById = new Map(projects.map((project) => [project.id, project]))
const checkoutOptions = checkoutSetups.flatMap((setup) => {
const project = projectById.get(setup.projectId)
return project
? [
{
kind: 'project' as const,
id: getProjectCheckoutOptionId(setup),
projectId: project.id,
displayName: eligibleRepoById.get(setup.repoId)?.displayName || setup.displayName,
badgeColor: project.badgeColor,
detail: getCheckoutDetail(setup, hostLabelById),
checkout: { projectHostSetupId: setup.id, hostId: setup.hostId, repoId: setup.repoId }
}
]
: []
})
return [
...options.map(({ detailSource: _detailSource, ...option }) => {
const directoryDetail = duplicateProjectDetailsById.get(option.id)
return directoryDetail ? { ...option, detail: directoryDetail } : option
})
.sort((a, b) => a.displayName.localeCompare(b.displayName) || a.detail.localeCompare(b.detail))
}),
...checkoutOptions
].sort((a, b) => a.displayName.localeCompare(b.displayName) || a.detail.localeCompare(b.detail))
}
// Why: a remote host's checkouts can share a name and path with this Mac's, so name the host.
function getCheckoutDetail(
setup: Pick<ProjectHostSetup, 'hostId' | 'path'>,
hostLabelById: ReadonlyMap<ExecutionHostId, string>
): string {
if (setup.hostId === LOCAL_EXECUTION_HOST_ID) {
return setup.path
}
const hostLabel = hostLabelById.get(setup.hostId)?.trim() || getExecutionHostLabel(setup.hostId)
return `${hostLabel} · ${setup.path}`
}
// Setup ids can repeat across hosts, so a checkout is keyed by both.
function getCheckoutKey(projectHostSetupId: string, hostId: ExecutionHostId): string {
return JSON.stringify([hostId, projectHostSetupId])
}
function getProjectCheckoutOptionId(setup: Pick<ProjectHostSetup, 'id' | 'hostId'>): string {
return `${NEW_WORKSPACE_PROJECT_CHECKOUT_OPTION_PREFIX}${getCheckoutKey(setup.id, setup.hostId)}`
}
/** The picker row for the composer's resolved setup: its checkout row when it has one, else its project row. */
export function getSelectedNewWorkspaceProjectOptionId(
options: readonly NewWorkspaceProjectOption[],
selectedProjectId: string | null,
projectHostSetupId: string | null | undefined,
hostId: ExecutionHostId | null | undefined
): string | null {
const checkoutOption = options.find(
(option) =>
option.kind === 'project' &&
option.checkout !== undefined &&
option.checkout.projectHostSetupId === projectHostSetupId &&
option.checkout.hostId === hostId
)
return checkoutOption?.id ?? selectedProjectId
}
/** Setups a project row stands for; its checkout rows own the rest. */
export function getProjectRowHostSetups(
options: readonly NewWorkspaceProjectOption[],
projectHostSetups: readonly ProjectHostSetup[]
): readonly ProjectHostSetup[] {
const checkoutKeys = new Set(
options.flatMap((option) =>
option.kind === 'project' && option.checkout
? [getCheckoutKey(option.checkout.projectHostSetupId, option.checkout.hostId)]
: []
)
)
return checkoutKeys.size === 0
? projectHostSetups
: projectHostSetups.filter((setup) => !checkoutKeys.has(getCheckoutKey(setup.id, setup.hostId)))
}
function getProjectGroupOptionId(projectGroupId: string): string {
@@ -16,7 +16,10 @@ import {
import type { ProjectHostSetup } from '../../../shared/project-types'
import type { Repo } from '../../../shared/repo-types'
import { translate } from '@/i18n/i18n'
import { dedupeReadySetupRows } from './project-host-setup-row-dedupe'
import {
dedupeReadySetupRows,
type SelectedProjectHostSetup
} from './project-host-setup-row-dedupe'
export type ProjectHostSetupSelection = { id: string; hostId: ExecutionHostId }
@@ -63,6 +66,7 @@ type BuildReadySetupOptionsInput = {
projectHostSetups: readonly ProjectHostSetup[]
eligibleRepos: readonly Repo[]
hosts: readonly ExecutionHostRegistryEntry[]
selectedSetup?: SelectedProjectHostSetup | null
}
type BuildNeedsSetupOptionsInput = {
@@ -77,13 +81,16 @@ type BuildProjectHostSetupOptionsInput = {
projectHostSetups: readonly ProjectHostSetup[]
eligibleRepos: readonly Repo[]
hosts: readonly ExecutionHostRegistryEntry[]
/** Shown as its host's row when that host has several checkouts of the project. */
selectedSetup?: SelectedProjectHostSetup | null
}
export function buildProjectHostSetupOptions({
projectId,
projectHostSetups,
eligibleRepos,
hosts
hosts,
selectedSetup
}: BuildProjectHostSetupOptionsInput): ProjectHostSetupOption[] {
if (!projectId) {
return []
@@ -92,7 +99,8 @@ export function buildProjectHostSetupOptions({
projectId,
projectHostSetups,
eligibleRepos,
hosts
hosts,
selectedSetup
})
const readySetupByHost = new Map(readyOptions.map((option) => [option.hostId, option]))
const pendingSetupByHost = getPendingSetupByHost(projectId, projectHostSetups)
@@ -127,7 +135,8 @@ function buildReadySetupOptions({
projectId,
projectHostSetups,
eligibleRepos,
hosts
hosts,
selectedSetup
}: BuildReadySetupOptionsInput): ReadyProjectHostSetupOption[] {
const eligibleRepoIds = new Set(eligibleRepos.map((repo) => repo.id))
const hostById = new Map(hosts.map((host) => [host.id, host]))
@@ -153,7 +162,7 @@ function buildReadySetupOptions({
detail: setup.displayName,
path: setup.path
}))
return dedupeReadySetupRows(options, hostById)
return dedupeReadySetupRows(options, hostById, selectedSetup)
}
function buildNeedsSetupOptions({
@@ -5,15 +5,33 @@ import type {
ReadyProjectHostSetupOption
} from './project-host-setup-options'
export type SelectedProjectHostSetup = { projectHostSetupId: string; hostId: ExecutionHostId }
/** Collapses ready setup rows to one per host, then one per checkout on a merged machine. */
export function dedupeReadySetupRows(
options: readonly ReadyProjectHostSetupOption[],
hostById: ReadonlyMap<ExecutionHostId, ExecutionHostRegistryEntry>
hostById: ReadonlyMap<ExecutionHostId, ExecutionHostRegistryEntry>,
selected?: SelectedProjectHostSetup | null
): ReadyProjectHostSetupOption[] {
return collapseMergedHostCheckouts(options.filter(dedupeByHost()), hostById)
return collapseMergedHostCheckouts(
selectedFirst(options, selected).filter(dedupeByHost()),
hostById
)
}
// Creation uses the first project+host setup, so display that same row; legacy duplicates (e.g. linked worktree projects) are otherwise identical and unreachable.
// Per-host dedupe keeps the first row, so a host with several checkouts shows the selected one.
function selectedFirst(
options: readonly ReadyProjectHostSetupOption[],
selected: SelectedProjectHostSetup | null | undefined
): readonly ReadyProjectHostSetupOption[] {
const isSelected = (option: ReadyProjectHostSetupOption): boolean =>
option.id === selected?.projectHostSetupId && option.hostId === selected.hostId
return selected
? [...options.filter(isSelected), ...options.filter((option) => !isSelected(option))]
: options
}
// One row per host; the project picker lists a host's other checkouts, so Run on only switches host.
function dedupeByHost(): (option: ReadyProjectHostSetupOption) => boolean {
const seenHosts = new Set<ExecutionHostId>()
return (option) => {
@@ -160,20 +160,21 @@ describe('project-host workspace target resolution', () => {
})
})
it('canonicalizes a stale same-host setup id to the setup the picker shows', () => {
// Legacy drafts can name hidden duplicate setups; creation must use the canonical picker row/path.
const repos = [makeRepo('orca-main'), makeRepo('orca-worktree')]
const projects = [makeProject('github:stablyai/orca', ['orca-main', 'orca-worktree'])]
it('canonicalizes a duplicate setup of the same checkout to the first one', () => {
const repos = [makeRepo('orca-main'), makeRepo('orca-twin')]
const projects = [makeProject('github:stablyai/orca', ['orca-main', 'orca-twin'])]
const projectHostSetups = [
makeSetup('orca-main', 'github:stablyai/orca', 'local', 'orca-main'),
makeSetup('orca-worktree', 'github:stablyai/orca', 'local', 'orca-worktree')
makeSetup('orca-twin', 'github:stablyai/orca', 'local', 'orca-twin', {
path: '/repos/orca-main/'
})
]
const resolution = resolveWorkspaceCreationTarget({
eligibleRepos: repos,
projects,
projectHostSetups,
projectHostSetupId: 'orca-worktree'
projectHostSetupId: 'orca-twin'
})
expect(resolution).toMatchObject({
@@ -182,6 +183,28 @@ describe('project-host workspace target resolution', () => {
})
})
it('keeps an explicit setup for a second checkout of the project on the same host', () => {
// Two clones of one remote share a project; the picker lists each, so the chosen one must be created in.
const repos = [makeRepo('orca-main'), makeRepo('orca-review')]
const projects = [makeProject('github:stablyai/orca', ['orca-main', 'orca-review'])]
const projectHostSetups = [
makeSetup('orca-main', 'github:stablyai/orca', 'local', 'orca-main'),
makeSetup('orca-review', 'github:stablyai/orca', 'local', 'orca-review')
]
const resolution = resolveWorkspaceCreationTarget({
eligibleRepos: repos,
projects,
projectHostSetups,
projectHostSetupId: 'orca-review'
})
expect(resolution).toMatchObject({
status: 'ready',
target: { projectHostSetupId: 'orca-review', repoId: 'orca-review', hostId: 'local' }
})
})
it('does not merge same-name repos without shared project identity', () => {
const repos = [
makeRepo('personal-orca', { displayName: 'orca' }),
@@ -12,6 +12,7 @@ import type { Project, ProjectHostSetup } from '../../../shared/project-types'
import type { Repo } from '../../../shared/repo-types'
import { resolveComposerRepoId } from './new-workspace-composer-repo'
import { findSoleHostCopy } from '../../../shared/worktree/host-qualified-identity'
import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path'
export type WorkspaceCreationTarget = {
projectId: string
@@ -183,12 +184,16 @@ export function resolveWorkspaceCreationTarget(
if (!isReadySetup(setup)) {
return { status: 'unavailable', reason: 'setup-not-ready' }
}
// Canonicalize legacy same-host duplicate drafts to the picker’s first setup so displayed and created paths match.
// Canonicalize legacy same-checkout duplicates to the first setup; a distinct same-host checkout is its own target.
const checkoutPath = normalizeRuntimePathForComparison(setup.path)
const canonical =
findReadySetupTarget(
setups,
reposById,
(entry) => entry.projectId === setup.projectId && entry.hostId === setup.hostId
(entry) =>
entry.projectId === setup.projectId &&
entry.hostId === setup.hostId &&
normalizeRuntimePathForComparison(entry.path) === checkoutPath
) ?? createTarget(setup, reposById)
if (canonical) {
return { status: 'ready', target: canonical }