From 2b33fed6a55882ded6f319c7a4a563b86ba57388 Mon Sep 17 00:00:00 2001 From: just-zzp Date: Sat, 24 Oct 2026 11:19:41 +0900 Subject: [PATCH] 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 * docs(sidebar): document the CompleteGitRepoAdd handoff contract Co-Authored-By: Claude Fable 5 --------- Co-authored-by: just-zzp <264744947+just-zzp@users.noreply.github.com> Co-authored-by: Claude Fable 5 Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- .../components/NewWorkspaceComposerModal.tsx | 6 +- .../use-add-repo-hosted-controller.test.ts | 20 ++- .../sidebar/use-add-repo-hosted-controller.ts | 17 ++- .../sidebar/use-complete-git-repo-add.ts | 26 ++-- .../useAddRepoNestedImportFlow.test.ts | 47 +++++++ .../sidebar/useAddRepoNestedImportFlow.ts | 17 ++- .../composer-state/composer-source-model.ts | 1 + .../composer-source-state-contract.ts | 2 +- .../imported-project-group-selection.test.tsx | 68 +++++++++ .../composer-state/project-target-actions.ts | 131 +++++++++++------- src/renderer/src/hooks/useComposerState.ts | 4 +- 11 files changed, 264 insertions(+), 75 deletions(-) create mode 100644 src/renderer/src/hooks/composer-state/imported-project-group-selection.test.tsx diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 17f2c3d221b..b8837322051 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -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 diff --git a/src/renderer/src/components/sidebar/use-add-repo-hosted-controller.test.ts b/src/renderer/src/components/sidebar/use-add-repo-hosted-controller.test.ts index 1c4bd303a1d..e6049cf3f64 100644 --- a/src/renderer/src/components/sidebar/use-add-repo-hosted-controller.test.ts +++ b/src/renderer/src/components/sidebar/use-add-repo-hosted-controller.test.ts @@ -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) + } + ) }) diff --git a/src/renderer/src/components/sidebar/use-add-repo-hosted-controller.ts b/src/renderer/src/components/sidebar/use-add-repo-hosted-controller.ts index 8d33646ea02..b00cd6abc86 100644 --- a/src/renderer/src/components/sidebar/use-add-repo-hosted-controller.ts +++ b/src/renderer/src/components/sidebar/use-add-repo-hosted-controller.ts @@ -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 + onProjectGroupImported?: (groupId: string) => boolean | Promise /** 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) | 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( () => hostedOnOpenChange && hostedOnProjectAdded - ? async (repoId: string): Promise => { + ? async (repoId, _source, _executionHostId, outcome): Promise => { 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( () => diff --git a/src/renderer/src/components/sidebar/use-complete-git-repo-add.ts b/src/renderer/src/components/sidebar/use-complete-git-repo-add.ts index 11f0f27fbca..95906bd2cf5 100644 --- a/src/renderer/src/components/sidebar/use-complete-git-repo-add.ts +++ b/src/renderer/src/components/sidebar/use-complete-git-repo-add.ts @@ -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 + 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 + finishProjectAdd?: CompleteGitRepoAdd } export function useCompleteGitRepoAdd({ closeModal, setHideDefaultBranchWorkspace, finishProjectAdd -}: CompleteGitRepoAddOptions): ( - repoId: string, - source: AddRepoExistingWorkspaceSource, - executionHostId?: ExecutionHostId -) => Promise { +}: CompleteGitRepoAddOptions): CompleteGitRepoAdd { const detectedTelemetryTrackedRef = useRef>(new Set()) return useCallback( async ( repoId: string, source: AddRepoExistingWorkspaceSource, - executionHostId?: ExecutionHostId + executionHostId?: ExecutionHostId, + outcome?: GitRepoAddOutcome ): Promise => { 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({ diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts index c5a03d6ca39..9b4ed9ef1e0 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts @@ -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 + ) + } + ) }) diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts index 5b340d949d9..064c77898e0 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts @@ -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 getNestedRepoRuntimeKind: (connectionId: string | null) => NestedRepoTelemetryRuntimeKind - onGitRepoReady: ( - repoId: string, - source: AddRepoExistingWorkspaceSource, - executionHostId?: ExecutionHostId - ) => Promise + onGitRepoReady: CompleteGitRepoAdd setIsAdding: (isAdding: boolean) => void }): { handleImportNestedRepos: (mode: 'group' | 'separate') => Promise @@ -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) { diff --git a/src/renderer/src/hooks/composer-state/composer-source-model.ts b/src/renderer/src/hooks/composer-state/composer-source-model.ts index 687d26fefbe..c0e5bc5f09e 100644 --- a/src/renderer/src/hooks/composer-state/composer-source-model.ts +++ b/src/renderer/src/hooks/composer-state/composer-source-model.ts @@ -62,6 +62,7 @@ export type ComposerSourceModel = { onConnectSelectedRepo: () => Promise prefetchSshConnectedGeneration: number resolvePendingSmartGitHubSubmit: () => Promise + selectAddedProjectGroup: (groupId: string) => boolean selectAddedProjectRepo: (nextRepoId: string) => void showProjectRequiredError: () => void smartNameSelection: SmartWorkspaceNameSelection | null diff --git a/src/renderer/src/hooks/composer-state/composer-source-state-contract.ts b/src/renderer/src/hooks/composer-state/composer-source-state-contract.ts index 10206c9d40b..e89179c4066 100644 --- a/src/renderer/src/hooks/composer-state/composer-source-state-contract.ts +++ b/src/renderer/src/hooks/composer-state/composer-source-state-contract.ts @@ -22,7 +22,7 @@ export type ComposerSourceState = { ComposerModel, 'handleRepoChange' | 'handleFolderSourceRepoChange' | 'handleProjectHostSetupChange' > - projectTargetActions: Pick + projectTargetActions: Pick branchStartPointActions: Pick< ComposerModel, | 'showProjectRequiredError' diff --git a/src/renderer/src/hooks/composer-state/imported-project-group-selection.test.tsx b/src/renderer/src/hooks/composer-state/imported-project-group-selection.test.tsx new file mode 100644 index 00000000000..241b69fecdc --- /dev/null +++ b/src/renderer/src/hooks/composer-state/imported-project-group-selection.test.tsx @@ -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 }) + } + ) +}) 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 6850766cb5f..202ccc9f6ab 100644 --- a/src/renderer/src/hooks/composer-state/project-target-actions.ts +++ b/src/renderer/src/hooks/composer-state/project-target-actions.ts @@ -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 } } diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index eb6762580d3..e44cfbe1491 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -73,6 +73,7 @@ export type UseComposerStateResult = { nameInputRef: RefObject submitQuick: (agent: TuiAgent | null, options?: QuickSubmitOptions) => Promise 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 } }