diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx
index ec6294c0839..2287e72bb79 100644
--- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx
+++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx
@@ -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({
{
+ originalApi = Object.getOwnPropertyDescriptor(window, 'api')
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: {
+ preflight: {
+ detectAgents: vi.fn().mockResolvedValue([])
+ },
+ gh: {
+ repoSlug: vi.fn().mockResolvedValue(null)
+ },
+ worktrees: {
+ listRetiredNames: vi
+ .fn()
+ .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)
+ })
+})
diff --git a/src/renderer/src/hooks/composer-state/composer-source-state.ts b/src/renderer/src/hooks/composer-state/composer-source-state.ts
index 5780dae0f9b..0f7c515ae38 100644
--- a/src/renderer/src/hooks/composer-state/composer-source-state.ts
+++ b/src/renderer/src/hooks/composer-state/composer-source-state.ts
@@ -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,
diff --git a/src/renderer/src/hooks/composer-state/composer-target-state.jira-mapping.test.tsx b/src/renderer/src/hooks/composer-state/composer-target-state.jira-mapping.test.tsx
index c163ae97179..fb78a27ffaf 100644
--- a/src/renderer/src/hooks/composer-state/composer-target-state.jira-mapping.test.tsx
+++ b/src/renderer/src/hooks/composer-state/composer-target-state.jira-mapping.test.tsx
@@ -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')
})
diff --git a/src/renderer/src/hooks/composer-state/project-target-actions.ts b/src/renderer/src/hooks/composer-state/project-target-actions.ts
index 21d435fb903..7d8bab08ba4 100644
--- a/src/renderer/src/hooks/composer-state/project-target-actions.ts
+++ b/src/renderer/src/hooks/composer-state/project-target-actions.ts
@@ -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
]
)
diff --git a/src/renderer/src/hooks/composer-state/runtime-target-selection.ts b/src/renderer/src/hooks/composer-state/runtime-target-selection.ts
index 6304f8faa72..f383b856586 100644
--- a/src/renderer/src/hooks/composer-state/runtime-target-selection.ts
+++ b/src/renderer/src/hooks/composer-state/runtime-target-selection.ts
@@ -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(
diff --git a/src/renderer/src/lib/new-workspace-project-checkout-options.test.ts b/src/renderer/src/lib/new-workspace-project-checkout-options.test.ts
new file mode 100644
index 00000000000..bf4c2f34ac1
--- /dev/null
+++ b/src/renderer/src/lib/new-workspace-project-checkout-options.test.ts
@@ -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 {
+ 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 & Partial
+): 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
+ )
+ })
+})
diff --git a/src/renderer/src/lib/new-workspace-project-options.ts b/src/renderer/src/lib/new-workspace-project-options.ts
index 2aa187211f8..4d051c24815 100644
--- a/src/renderer/src/lib/new-workspace-project-options.ts
+++ b/src/renderer/src/lib/new-workspace-project-options.ts
@@ -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()
const setupDirectoriesByProjectId = new Map()
+ 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,
+ hostLabelById: ReadonlyMap
+): 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): 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 {
diff --git a/src/renderer/src/lib/project-host-setup-options.ts b/src/renderer/src/lib/project-host-setup-options.ts
index 80d1eeaf1a1..ea365099ce5 100644
--- a/src/renderer/src/lib/project-host-setup-options.ts
+++ b/src/renderer/src/lib/project-host-setup-options.ts
@@ -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({
diff --git a/src/renderer/src/lib/project-host-setup-row-dedupe.ts b/src/renderer/src/lib/project-host-setup-row-dedupe.ts
index 9b203f109e2..585630fa4ba 100644
--- a/src/renderer/src/lib/project-host-setup-row-dedupe.ts
+++ b/src/renderer/src/lib/project-host-setup-row-dedupe.ts
@@ -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
+ hostById: ReadonlyMap,
+ 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()
return (option) => {
diff --git a/src/renderer/src/lib/project-host-workspace-target.test.ts b/src/renderer/src/lib/project-host-workspace-target.test.ts
index 211769880ad..ad3d94b71fa 100644
--- a/src/renderer/src/lib/project-host-workspace-target.test.ts
+++ b/src/renderer/src/lib/project-host-workspace-target.test.ts
@@ -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' }),
diff --git a/src/renderer/src/lib/project-host-workspace-target.ts b/src/renderer/src/lib/project-host-workspace-target.ts
index 5c60f6fbc61..c082614e5bc 100644
--- a/src/renderer/src/lib/project-host-workspace-target.ts
+++ b/src/renderer/src/lib/project-host-workspace-target.ts
@@ -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 }