fix(composer): retain the imported project group target (#9551)

* fix(composer): keep the imported group as the target after a grouped nested import

Grouped folder imports create a ProjectGroup, but the hosted Add Project
handoff selected only the first imported repo, dropping the group the user
explicitly chose. Carry the root group id through the completion path and
select the group's folder-workspace target in the composer, falling back to
the single-project handoff when no group was created or it is no longer
selectable. Sidebar default-checkout and separate imports are unchanged.

Part A of #9529.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(sidebar): document the CompleteGitRepoAdd handoff contract

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: just-zzp <264744947+just-zzp@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
just-zzp
2026-10-23 19:19:41 -07:00
committed by GitHub
co-authored by Claude Fable 5 just-zzp Neil
parent b46be798ed
commit 2b33fed6a5
11 changed files with 264 additions and 75 deletions
@@ -139,7 +139,8 @@ function QuickTabBody({
nameInputRef,
submitQuick,
createDisabled: formDisabled,
selectAddedProjectRepo
selectAddedProjectRepo,
selectAddedProjectGroup
} = useComposerState({
initialName: modalData.prefilledName ?? '',
// Why: the modal is quick-create only now, so prompt-prefill state is
@@ -283,9 +284,10 @@ function QuickTabBody({
open: addProjectOpen,
onOpenChange: setAddProjectOpen,
onProjectAdded: handleProjectAdded,
onProjectGroupImported: selectAddedProjectGroup,
onCloseAutoFocus: handleAddProjectCloseAutoFocus
}),
[addProjectOpen, handleAddProjectCloseAutoFocus, handleProjectAdded]
[addProjectOpen, handleAddProjectCloseAutoFocus, handleProjectAdded, selectAddedProjectGroup]
)
const selectedProjectOption = cardProps.projectOptions.find(
(option) => option.id === cardProps.selectedProjectId
@@ -83,11 +83,29 @@ describe('useAddRepoHostedController', () => {
onOpenChange,
onProjectAdded
})
await finishProjectAdd?.('repo-1')
await finishProjectAdd?.('repo-1', 'local_folder_picker')
expect(mocks.markOnboardingProjectAdded).toHaveBeenCalledWith('addedRepo')
expect(onProjectAdded).toHaveBeenCalledWith('repo-1')
// Why: closing before selection keeps the composer visible under the
// dialog's close animation while the new project lands in the picker.
expect(order).toEqual(['close', 'added'])
})
it.each([true, false])(
'prefers the imported group when host accepts it (%s)',
async (accepted) => {
const onProjectAdded = vi.fn()
const onProjectGroupImported = vi.fn(() => accepted)
const { finishProjectAdd } = useAddRepoHostedController({
open: true,
onOpenChange: vi.fn(),
onProjectAdded,
onProjectGroupImported
})
await finishProjectAdd?.('repo-1', 'ssh_remote_path', 'ssh:box', {
importedProjectGroupId: 'group'
})
expect(onProjectGroupImported).toHaveBeenCalledWith('group')
expect(onProjectAdded).toHaveBeenCalledTimes(accepted ? 0 : 1)
}
)
})
@@ -1,3 +1,4 @@
import type { CompleteGitRepoAdd } from './use-complete-git-repo-add'
import { useMemo } from 'react'
import { useAppStore } from '@/store'
import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist'
@@ -10,6 +11,7 @@ export type AddRepoDialogHostedController = {
/** Called after a Git project is added; the host selects the new project
* instead of running the default-checkout navigation handoff. */
onProjectAdded: (repoId: string) => void | Promise<void>
onProjectGroupImported?: (groupId: string) => boolean | Promise<boolean>
/** Radix close-autofocus hook for the nested dialog. Lets the host redirect
* focus (e.g. to the composer's name field) instead of Radix restoring it
* to the already-unmounted combobox row that opened the dialog. */
@@ -23,27 +25,34 @@ export function useAddRepoHostedController(hosted: AddRepoDialogHostedController
* composer selection. Hosted mode must close the composer too — leaving it
* open would hide the navigation behind a stale project selection. */
closeForFolderHandoff: () => void
finishProjectAdd: ((repoId: string) => Promise<void>) | undefined
finishProjectAdd: CompleteGitRepoAdd | undefined
} {
const storeCloseModal = useAppStore((s) => s.closeModal)
const hostedOnOpenChange = hosted?.onOpenChange
const hostedOnProjectAdded = hosted?.onProjectAdded
const hostedOnProjectGroupImported = hosted?.onProjectGroupImported
// Why: hosted mode (nested inside the workspace composer) must close only
// this dialog, never the composer modal that lives in the activeModal slot.
const closeModal = useMemo(
() => (hostedOnOpenChange ? () => hostedOnOpenChange(false) : storeCloseModal),
[hostedOnOpenChange, storeCloseModal]
)
const finishProjectAdd = useMemo(
const finishProjectAdd = useMemo<CompleteGitRepoAdd | undefined>(
() =>
hostedOnOpenChange && hostedOnProjectAdded
? async (repoId: string): Promise<void> => {
? async (repoId, _source, _executionHostId, outcome): Promise<void> => {
await markOnboardingProjectAdded('addedRepo')
hostedOnOpenChange(false)
if (
outcome?.importedProjectGroupId &&
(await hostedOnProjectGroupImported?.(outcome.importedProjectGroupId))
) {
return
}
await hostedOnProjectAdded(repoId)
}
: undefined,
[hostedOnOpenChange, hostedOnProjectAdded]
[hostedOnOpenChange, hostedOnProjectAdded, hostedOnProjectGroupImported]
)
const closeForFolderHandoff = useMemo(
() =>
@@ -11,35 +11,37 @@ import { finishProjectAddWithDefaultCheckout } from './project-added-default-che
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { selectWorktreesByRepo } from '@/store/workspace-record-selectors'
export type GitRepoAddOutcome = { importedProjectGroupId?: string }
export type CompleteGitRepoAdd = (
repoId: string,
source: AddRepoExistingWorkspaceSource,
executionHostId?: ExecutionHostId,
outcome?: GitRepoAddOutcome
) => Promise<void>
type CompleteGitRepoAddOptions = {
closeModal: () => void
setHideDefaultBranchWorkspace: (hide: boolean) => void
/** Why: the nested Add Project flow (hosted inside the workspace composer)
* keeps the composer open and selects the new project instead of running
* the default-checkout navigation handoff. Telemetry above still applies. */
finishProjectAdd?: (
repoId: string,
source: AddRepoExistingWorkspaceSource,
executionHostId?: ExecutionHostId
) => Promise<void>
finishProjectAdd?: CompleteGitRepoAdd
}
export function useCompleteGitRepoAdd({
closeModal,
setHideDefaultBranchWorkspace,
finishProjectAdd
}: CompleteGitRepoAddOptions): (
repoId: string,
source: AddRepoExistingWorkspaceSource,
executionHostId?: ExecutionHostId
) => Promise<void> {
}: CompleteGitRepoAddOptions): CompleteGitRepoAdd {
const detectedTelemetryTrackedRef = useRef<Set<string>>(new Set())
return useCallback(
async (
repoId: string,
source: AddRepoExistingWorkspaceSource,
executionHostId?: ExecutionHostId
executionHostId?: ExecutionHostId,
outcome?: GitRepoAddOutcome
): Promise<void> => {
const worktrees = (selectWorktreesByRepo(useAppStore.getState())[repoId] ?? []).filter(
(worktree) =>
@@ -66,7 +68,7 @@ export function useCompleteGitRepoAdd({
track('add_repo_existing_workspaces_detected', existingWorkspaceTelemetry)
}
if (finishProjectAdd) {
await finishProjectAdd(repoId, source, executionHostId)
await finishProjectAdd(repoId, source, executionHostId, outcome)
return
}
await finishProjectAddWithDefaultCheckout({
@@ -248,4 +248,51 @@ describe('useAddRepoNestedImportFlow open folder fallback', () => {
})
expect(onGitRepoReady).not.toHaveBeenCalled()
})
it.each(['group', 'separate'] as const)(
'carries grouped import outcome without losing SSH owner (%s)',
async (mode) => {
const importedRepo: Repo = {
...folderRepo,
id: 'app',
kind: 'git',
path: scan.repos[0].path,
connectionId: 'ssh-builder'
}
mocks.state.repos = [importedRepo]
const onGitRepoReady = vi.fn()
const result: ProjectGroupImportResult = {
group: {
id: 'group',
name: 'platform',
parentPath: scan.selectedPath,
connectionId: 'ssh-builder',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
},
projects: [{ path: importedRepo.path, projectId: importedRepo.id, status: 'imported' }],
importedCount: 1,
alreadyKnownCount: 0,
failedCount: 0
}
const flow = useTestAddRepoNestedImportFlow({
nestedConnectionId: 'ssh-builder',
nestedRuntimeKind: 'ssh',
nestedSelectedPaths: new Set([importedRepo.path]),
importNestedRepos: vi.fn().mockResolvedValue(result),
onGitRepoReady
})
await flow.handleImportNestedRepos(mode)
expect(onGitRepoReady).toHaveBeenCalledWith(
importedRepo.id,
'ssh_remote_path',
'ssh:ssh-builder',
mode === 'group' ? { importedProjectGroupId: 'group' } : undefined
)
}
)
})
@@ -19,7 +19,7 @@ import { translate } from '@/i18n/i18n'
import { worktreeRefreshOptions } from './add-repo-runtime-owner'
import { completeNestedFolderOpen } from './complete-nested-folder-open'
import { defaultProjectGroupNameForPath } from './add-repo-dialog-types'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { CompleteGitRepoAdd } from './use-complete-git-repo-add'
export function useAddRepoNestedImportFlow({
nestedAttemptId,
@@ -59,11 +59,7 @@ export function useAddRepoNestedImportFlow({
mode: 'group' | 'separate'
}) => Promise<ProjectGroupImportResult | null>
getNestedRepoRuntimeKind: (connectionId: string | null) => NestedRepoTelemetryRuntimeKind
onGitRepoReady: (
repoId: string,
source: AddRepoExistingWorkspaceSource,
executionHostId?: ExecutionHostId
) => Promise<void>
onGitRepoReady: CompleteGitRepoAdd
setIsAdding: (isAdding: boolean) => void
}): {
handleImportNestedRepos: (mode: 'group' | 'separate') => Promise<void>
@@ -208,7 +204,14 @@ export function useAddRepoNestedImportFlow({
: activeRuntimeEnvironmentId?.trim()
? 'runtime_server_path'
: 'local_folder_picker'
await onGitRepoReady(repo.id, source, completionOwnerOptions.executionHostId)
await onGitRepoReady(
repo.id,
source,
completionOwnerOptions.executionHostId,
mode === 'group' && result.group
? { importedProjectGroupId: result.group.id }
: undefined
)
}
} catch (err) {
if (gen === nestedImportGenRef.current) {
@@ -62,6 +62,7 @@ export type ComposerSourceModel = {
onConnectSelectedRepo: () => Promise<void>
prefetchSshConnectedGeneration: number
resolvePendingSmartGitHubSubmit: () => Promise<PendingSmartGitHubSubmitResolution>
selectAddedProjectGroup: (groupId: string) => boolean
selectAddedProjectRepo: (nextRepoId: string) => void
showProjectRequiredError: () => void
smartNameSelection: SmartWorkspaceNameSelection | null
@@ -22,7 +22,7 @@ export type ComposerSourceState = {
ComposerModel,
'handleRepoChange' | 'handleFolderSourceRepoChange' | 'handleProjectHostSetupChange'
>
projectTargetActions: Pick<ComposerModel, 'handleProjectChange'>
projectTargetActions: Pick<ComposerModel, 'handleProjectChange' | 'selectAddedProjectGroup'>
branchStartPointActions: Pick<
ComposerModel,
| 'showProjectRequiredError'
@@ -0,0 +1,68 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { useAppStore } from '@/store'
import type { ProjectGroup } from '../../../../shared/project-group-types'
import { pr, repo, useReviewBaseSelectionFixture } from './composer-review-base-test-fixture'
const originalState = useAppStore.getState()
const group: ProjectGroup = {
id: 'just-imported',
name: 'Imported group',
parentPath: '/imported',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
afterEach(() => {
cleanup()
useAppStore.setState(originalState, true)
})
describe('imported group composer selection', () => {
it('selects a group created after render through the same reset path as the picker', () => {
const { result } = renderHook(() => useReviewBaseSelectionFixture(pr))
const importedRepo = {
...repo,
id: 'imported-repo',
path: '/imported/app',
projectGroupId: group.id
}
act(() => useAppStore.setState({ projectGroups: [group], repos: [importedRepo] }))
let selected = false
act(() => {
selected = result.current.selectAddedProjectGroup(group.id)
})
expect(selected).toBe(true)
expect(result.current.selectedProjectGroupId).toBe(group.id)
expect(result.current.repoId).toBe(importedRepo.id)
expect(result.current.linkedWorkItem).toBeNull()
expect(result.current.baseBranch).toBeUndefined()
expect(result.current.branchNameOverride).toBeUndefined()
expect(result.current.pushTarget).toBeUndefined()
})
it.each([
{ ...group, id: 'different' },
{ ...group, parentPath: null },
{ ...group, executionHostId: 'ssh:missing' }
])(
'declines missing, non-folder or unreachable groups without changing selection (%j)',
(unavailable) => {
const { result } = renderHook(() => useReviewBaseSelectionFixture(pr))
act(() => useAppStore.setState({ projectGroups: [unavailable] }))
let selected = true
act(() => {
selected = result.current.selectAddedProjectGroup(group.id)
})
expect(selected).toBe(false)
expect(result.current.selectedProjectGroupId).toBeNull()
expect(result.current.repoId).toBe(repo.id)
expect(result.current.linkedWorkItem).toMatchObject({ type: 'pr', number: pr.number })
}
)
})
@@ -1,3 +1,6 @@
import { useAppStore } from '@/store'
import type { ProjectGroup } from '../../../../shared/project-group-types'
import type { Repo } from '../../../../shared/repo-types'
import type { ComposerModel } from './composer-model'
type ProjectTargetActionsInput = Pick<
@@ -84,6 +87,82 @@ export function useProjectTargetActions(input: ProjectTargetActionsInput) {
workspaceHostScope
} = input
const applyProjectGroupSelection = useCallback(
(
group: ProjectGroup,
sourceRepos: readonly Repo[],
sourceGroups: readonly ProjectGroup[]
): void => {
const nextSourceRepo = getFolderSourceRepos(sourceRepos, sourceGroups, group)[0]
smartSourceSelectionRef.current = null
setSelectedProjectGroupId(group.id)
setProjectError(null)
setRepoId(nextSourceRepo?.id ?? '')
setLinkedIssue('')
setLinkedPR(null)
setLinkedGitLabIssue(null)
setLinkedGitLabMR(null)
if (linkedWorkItem && !shouldPreserveWorkspaceSourceOnRepoChange(linkedWorkItem)) {
setLinkedWorkItem(null)
setLinkedTaskSourceContext(null)
}
setSparseEnabled(false)
setSparseDirectories('')
setSparseSelectedPresetId(null)
setBaseBranch(undefined)
setPushTarget(undefined)
setBranchNameOverride(undefined)
// Why (#5181): clear branch-scoped reuse state on a project switch too.
setBranchNameOverridePreservesNameEdits(false)
setReuseEligibleBranch(null)
setReuseSelectedBranch(false)
setForkPushWarning(null)
setStartFromResetHint(null)
},
[
linkedWorkItem,
setRepoId,
setSelectedProjectGroupId,
setProjectError,
setLinkedIssue,
setLinkedPR,
setLinkedGitLabIssue,
setLinkedGitLabMR,
setLinkedWorkItem,
setLinkedTaskSourceContext,
setSparseEnabled,
setSparseDirectories,
setSparseSelectedPresetId,
setBaseBranch,
setPushTarget,
setBranchNameOverride,
setBranchNameOverridePreservesNameEdits,
setReuseEligibleBranch,
setReuseSelectedBranch,
setForkPushWarning,
setStartFromResetHint,
smartSourceSelectionRef
]
)
const selectAddedProjectGroup = useCallback(
(groupId: string): boolean => {
const state = useAppStore.getState()
const group = findActionableFolderProjectGroup({
projectGroups: state.projectGroups,
groupId,
actionableHostIds
})
if (!group) {
return false
}
initialProjectGroupAppliedRef.current = true
applyProjectGroupSelection(group, state.repos, state.projectGroups)
return true
},
[actionableHostIds, initialProjectGroupAppliedRef, applyProjectGroupSelection]
)
const handleProjectChange = useCallback(
(projectId: string): void => {
initialProjectGroupAppliedRef.current = true
@@ -104,31 +183,7 @@ export function useProjectTargetActions(input: ProjectTargetActionsInput) {
)
return
}
const nextSourceRepo = getFolderSourceRepos(repos, projectGroups, nextProjectGroup)[0]
smartSourceSelectionRef.current = null
setSelectedProjectGroupId(nextProjectGroup.id)
setProjectError(null)
setRepoId(nextSourceRepo?.id ?? '')
setLinkedIssue('')
setLinkedPR(null)
setLinkedGitLabIssue(null)
setLinkedGitLabMR(null)
if (linkedWorkItem && !shouldPreserveWorkspaceSourceOnRepoChange(linkedWorkItem)) {
setLinkedWorkItem(null)
setLinkedTaskSourceContext(null)
}
setSparseEnabled(false)
setSparseDirectories('')
setSparseSelectedPresetId(null)
setBaseBranch(undefined)
setPushTarget(undefined)
setBranchNameOverride(undefined)
// Why (#5181): clear branch-scoped reuse state on a project switch too.
setBranchNameOverridePreservesNameEdits(false)
setReuseEligibleBranch(null)
setReuseSelectedBranch(false)
setForkPushWarning(null)
setStartFromResetHint(null)
applyProjectGroupSelection(nextProjectGroup, repos, projectGroups)
return
}
@@ -150,43 +205,25 @@ export function useProjectTargetActions(input: ProjectTargetActionsInput) {
handleRepoChange(nextRepoId, { forceResetStartFrom: isProjectGroupTarget })
},
[
applyProjectGroupSelection,
eligibleRepos,
actionableHostIds,
handleRepoChange,
isProjectGroupTarget,
linkedWorkItem,
projectGroups,
projectHostSetups,
projects,
repos,
setRepoId,
selectedWorkspaceTarget,
workspaceHostScope,
initialProjectGroupAppliedRef,
setBaseBranch,
setBranchNameOverride,
setBranchNameOverridePreservesNameEdits,
setForkPushWarning,
setLinkedGitLabIssue,
setLinkedGitLabMR,
setLinkedIssue,
setLinkedPR,
setLinkedTaskSourceContext,
setLinkedWorkItem,
setProjectError,
setPushTarget,
setReuseEligibleBranch,
setReuseSelectedBranch,
setSelectedProjectGroupId,
setSparseDirectories,
setSparseEnabled,
setSparseSelectedPresetId,
setStartFromResetHint,
smartSourceSelectionRef
setProjectError
]
)
return {
handleProjectChange
handleProjectChange,
selectAddedProjectGroup
}
}
+3 -1
View File
@@ -73,6 +73,7 @@ export type UseComposerStateResult = {
nameInputRef: RefObject<HTMLInputElement | null>
submitQuick: (agent: TuiAgent | null, options?: QuickSubmitOptions) => Promise<void>
createDisabled: boolean
selectAddedProjectGroup: (groupId: string) => boolean
selectAddedProjectRepo: (repoId: string) => void
}
@@ -104,6 +105,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
nameInputRef: model.nameInputRef,
submitQuick: model.submitQuick,
createDisabled,
selectAddedProjectRepo: model.selectAddedProjectRepo
selectAddedProjectRepo: model.selectAddedProjectRepo,
selectAddedProjectGroup: model.selectAddedProjectGroup
}
}