From 6a573f376f8a0f7bc4be0adc8bc05b91bd520967 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:36:52 -0700 Subject: [PATCH] fix(create-worktree): keep composer open when adding a project from the dialog (#9397) Co-authored-by: Orca --- .../components/NewWorkspaceComposerCard.tsx | 14 ++- .../components/NewWorkspaceComposerModal.tsx | 65 +++++++++- .../src/components/sidebar/AddRepoDialog.tsx | 53 ++++---- .../sidebar/AddRepoDialogChrome.tsx | 3 + .../use-add-repo-hosted-controller.test.ts | 113 ++++++++++++++++++ .../sidebar/use-add-repo-hosted-controller.ts | 72 +++++++++++ .../sidebar/use-complete-git-repo-add.ts | 13 +- .../useAddRepoNestedImportFlow.test.ts | 1 + .../sidebar/useAddRepoNestedImportFlow.ts | 12 +- src/renderer/src/hooks/useComposerState.ts | 19 ++- 10 files changed, 334 insertions(+), 31 deletions(-) create mode 100644 src/renderer/src/components/sidebar/use-add-repo-hosted-controller.test.ts create mode 100644 src/renderer/src/components/sidebar/use-add-repo-hosted-controller.ts diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index ab593fb3e5a..7bd2800e002 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -154,6 +154,9 @@ type NewWorkspaceComposerCardProps = { sparseSelectedPresetId: string | null onSparseSelectPreset: (preset: SparsePreset | null) => void sparseControlsEnabled?: boolean + /** When set, "Add project" opens a host-provided flow (e.g. a nested dialog + * over the composer modal) instead of swapping the store's active modal. */ + onAddProjectOverride?: () => void } const SSH_STATUS_LABELS: Partial> = { @@ -615,7 +618,8 @@ export default function NewWorkspaceComposerCard({ sparsePresets, sparseSelectedPresetId, onSparseSelectPreset, - sparseControlsEnabled = true + sparseControlsEnabled = true, + onAddProjectOverride }: NewWorkspaceComposerCardProps): React.JSX.Element { // Why: this form uses the lightweight translate() helper directly; subscribe // so an already-open create dialog repaints when the UI language changes. @@ -728,8 +732,14 @@ export default function NewWorkspaceComposerCard({ }, [detectedAgentIds, disabledTuiAgents]) const handleAddRepo = React.useCallback((): void => { + // Why: inside the composer modal, swapping activeModal would abruptly + // unmount the composer; the override layers Add Project on top instead. + if (onAddProjectOverride) { + onAddProjectOverride() + return + } openModal('add-repo') - }, [openModal]) + }, [onAddProjectOverride, openModal]) const handleNotePaste = React.useCallback((event: React.ClipboardEvent) => { const text = event.clipboardData.getData('text/plain') const byteLengthMeasurement = measureTextControlPasteByteLength(text, { diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 3bbac8e9676..fb27f84cb49 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -1,5 +1,6 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react' +import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react' import { useAppStore } from '@/store' +import { lazyWithRetry } from '@/lib/lazy-with-retry' import { Dialog, DialogContent, @@ -9,6 +10,7 @@ import { } from '@/components/ui/dialog' import NewWorkspaceComposerCard from '@/components/NewWorkspaceComposerCard' import AgentSettingsDialog from '@/components/agent/AgentSettingsDialog' +import type { AddRepoDialogHostedController } from '@/components/sidebar/use-add-repo-hosted-controller' import { useComposerState } from '@/hooks/useComposerState' import { pickQuickWorkspaceAgent, @@ -27,6 +29,12 @@ import { translate } from '@/i18n/i18n' import { getWorkspaceComposerInitialFocusTarget } from '@/lib/workspace-composer-initial-focus' import { getFolderWorkspacePrimaryActionLabel } from '@/components/sidebar/folder-workspace-composer-helpers' +// Why: match App-level AddRepoDialog loading — the add flow is off the hot +// path for the composer, so keep its clone/SSH machinery out of the entry render. +const HostedAddRepoDialog = lazyWithRetry(() => import('@/components/sidebar/AddRepoDialog'), { + reloadKey: 'composer-add-repo' +}) + type ComposerModalData = { prefilledName?: string initialRepoId?: string @@ -120,7 +128,8 @@ function QuickTabBody({ onComposerNodeChange, nameInputRef, submitQuick, - createDisabled + createDisabled, + selectAddedProjectRepo } = useComposerState({ initialName: modalData.prefilledName ?? '', // Why: the modal is quick-create only now, so prompt-prefill state is @@ -180,6 +189,44 @@ function QuickTabBody({ const handleCreate = useCallback(async (): Promise => { await submitQuick(quickAgent) }, [quickAgent, submitQuick]) + // Why: Add Project layers over the composer as a nested dialog instead of + // replacing it in the activeModal slot — closing the composer mid-flow (and + // losing the typed name/prompt) was the old, abrupt behavior. Once opened it + // stays mounted so cancel/complete plays the close animation and the + // dialog's close effects can abort in-flight clone/scan work. (Folder/non-git + // outcomes still navigate away and tear the whole modal down.) + const [addProjectOpen, setAddProjectOpen] = useState(false) + const [addProjectMounted, setAddProjectMounted] = useState(false) + const handleOpenAddProject = useCallback((): void => { + setAddProjectMounted(true) + setAddProjectOpen(true) + }, []) + const handleProjectAdded = useCallback( + (repoId: string): void => { + selectAddedProjectRepo(repoId) + }, + [selectAddedProjectRepo] + ) + const handleAddProjectCloseAutoFocus = useCallback( + (event: Event): void => { + // Why: after adding a project the next step is naming the worktree. + // Radix would try to restore focus to the (unmounted) combobox row that + // opened the dialog; send it to the name field instead — the same place + // picking a project from the combobox lands. + event.preventDefault() + nameInputRef?.current?.focus() + }, + [nameInputRef] + ) + const addProjectController = useMemo( + () => ({ + open: addProjectOpen, + onOpenChange: setAddProjectOpen, + onProjectAdded: handleProjectAdded, + onCloseAutoFocus: handleAddProjectCloseAutoFocus + }), + [addProjectOpen, handleAddProjectCloseAutoFocus, handleProjectAdded] + ) const selectedProjectOption = cardProps.projectOptions.find( (option) => option.id === cardProps.selectedProjectId ) @@ -191,8 +238,12 @@ function QuickTabBody({ : translate('auto.components.NewWorkspaceComposerModal.createWorkspace', 'Create workspace') // Cmd/Ctrl+Enter submits, Esc first blurs the focused input (like the full page). + const nestedDialogOpen = agentSettingsOpen || addProjectOpen useEffect(() => { - if (!active) { + if (!active || nestedDialogOpen) { + // Why: while a nested dialog (Add Project / Agents) is layered on top, + // this capture-phase handler must not steal its Escape (which should + // close only the nested dialog) or fire composer submit underneath it. return } const onKeyDown = (event: KeyboardEvent): void => { @@ -236,7 +287,7 @@ function QuickTabBody({ } window.addEventListener('keydown', onKeyDown, { capture: true }) return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) - }, [active, composerRef, createDisabled, handleCreate, onClose]) + }, [active, composerRef, createDisabled, handleCreate, nestedDialogOpen, onClose]) return ( <> @@ -271,8 +322,14 @@ function QuickTabBody({ primaryActionLabel={primaryActionLabel} onOpenAgentSettings={() => setAgentSettingsOpen(true)} onCreate={() => void handleCreate()} + onAddProjectOverride={handleOpenAddProject} /> + {addProjectMounted ? ( + + + + ) : null} ) } diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index 1c5f27c629b..be41bb17534 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -16,24 +16,36 @@ import { useAddRepoHostChangeReset } from './use-add-repo-host-change-reset' import { AddRepoDialogChrome } from './AddRepoDialogChrome' import { AddRepoHostSelectorSlot } from './AddRepoHostSelectorSlot' import { useAddRepoRemoteNestedScan } from './use-add-repo-remote-nested-scan' +import { + useAddRepoHostedController, + type AddRepoDialogHostedController +} from './use-add-repo-hosted-controller' -const AddRepoDialog = React.memo(function AddRepoDialog() { - const activeModal = useAppStore((s) => s.activeModal) - const modalData = useAppStore((s) => s.modalData) - const closeModal = useAppStore((s) => s.closeModal) +const AddRepoDialog = React.memo(function AddRepoDialog({ + hosted +}: { + hosted?: AddRepoDialogHostedController +}) { + const isOpen = useAppStore((s) => (hosted ? hosted.open : s.activeModal === 'add-repo')) + // Why: hosted mode never receives dropped paths through modalData — that + // channel belongs to the store-modal instance. + const droppedLocalPath = useAppStore((s) => + !hosted && typeof s.modalData.droppedLocalPath === 'string' ? s.modalData.droppedLocalPath : '' + ) const addRepoPath = useAppStore((s) => s.addRepoPath) const scanNestedRepos = useAppStore((s) => s.scanNestedRepos) const cancelNestedRepoScan = useAppStore((s) => s.cancelNestedRepoScan) const importNestedRepos = useAppStore((s) => s.importNestedRepos) const repos = useAppStore((s) => s.repos) const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) - const openSettingsPage = useAppStore((s) => s.openSettingsPage) - const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) const settings = useAppStore((s) => s.settings) + const { closeModal, closeForFolderHandoff, finishProjectAdd, handleOpenSshSettings } = + useAddRepoHostedController(hosted) const completeGitRepoAdd = useCompleteGitRepoAdd({ closeModal, - setHideDefaultBranchWorkspace + setHideDefaultBranchWorkspace, + finishProjectAdd }) const [step, setStep] = useState('add') @@ -63,7 +75,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { setStep }) - const hostSelection = useAddRepoHostSelection({ isOpen: activeModal === 'add-repo', setStep }) + const hostSelection = useAddRepoHostSelection({ isOpen, setStep }) const selectedRuntimeEnvironmentId = hostSelection.selectedParsedHost?.kind === 'runtime' ? hostSelection.selectedParsedHost.environmentId @@ -90,7 +102,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { } = useRemoteRepo( fetchWorktrees, setStep, - closeModal, + // Why: useRemoteRepo closes only for the non-git → confirm-dialog handoff. + closeForFolderHandoff, (repoId) => completeGitRepoAdd(repoId, 'ssh_remote_path'), scanNestedRepos, showRemoteNestedRepoReview, @@ -110,7 +123,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { handleCreate } = useCreateRepo( fetchWorktrees, - closeModal, + // Why: useCreateRepo closes only after a folder (non-git) create. + closeForFolderHandoff, (repoId) => completeGitRepoAdd(repoId, 'create_project'), { hostId: hostSelection.selectedHostId, @@ -155,9 +169,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { onGitRepoReady: completeGitRepoAdd }) - const isOpen = activeModal === 'add-repo' - const droppedLocalPath = - typeof modalData.droppedLocalPath === 'string' ? modalData.droppedLocalPath : '' const isRuntimeEnvironmentActive = Boolean(selectedRuntimeEnvironmentId) const selectedHostKind = hostSelection.selectedParsedHost?.kind const { handleBrowse, resetLocalFolderFlow } = useAddRepoLocalFolderFlow({ @@ -165,7 +176,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { droppedLocalPath, activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId, addRepoPath, - closeModal, + // Why: this flow's closes are all folder/non-git outcomes that navigate. + closeModal: closeForFolderHandoff, fetchWorktrees, scanNestedRepos, setActiveNestedScanId, @@ -183,7 +195,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { handleAddServerPath } = useAddRepoServerPathFlow({ addRepoPath, - closeModal, + // Why: closes only after a folder add, which activates the folder workspace. + closeModal: closeForFolderHandoff, fetchWorktrees, getNestedRepoRuntimeKind, scanNestedRepos, @@ -207,6 +220,9 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { nestedGroupName, nestedImportScanId, activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId, + // Why: open-as-folder outcomes navigate; git imports finish via + // completeGitRepoAdd instead. + closeModal: closeForFolderHandoff, fetchWorktrees, importNestedRepos, getNestedRepoRuntimeKind, @@ -289,6 +305,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { step={step} isAdding={isAdding} onBack={handleBack} + onCloseAutoFocus={hosted?.onCloseAutoFocus} onOpenChange={handleOpenChange} > { - closeModal() - openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' }) - openSettingsPage() - }} + onOpenSshSettings={handleOpenSshSettings} onConnectTarget={handleConnectTarget} onStopRemoteNestedScan={stopRemoteNestedScan} onCloneUrlChange={(value) => { diff --git a/src/renderer/src/components/sidebar/AddRepoDialogChrome.tsx b/src/renderer/src/components/sidebar/AddRepoDialogChrome.tsx index 5c27fd8dd22..8a4a1893b4d 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogChrome.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogChrome.tsx @@ -8,6 +8,7 @@ export function AddRepoDialogChrome({ isAdding, isOpen, onBack, + onCloseAutoFocus, onOpenChange, step }: { @@ -15,12 +16,14 @@ export function AddRepoDialogChrome({ isAdding: boolean isOpen: boolean onBack: () => void + onCloseAutoFocus?: (event: Event) => void onOpenChange: (open: boolean) => void step: AddRepoDialogStep }) { return ( *]:min-w-0 ${ step === 'nested' ? 'max-h-[calc(100vh-2rem)] grid-rows-[auto_auto_minmax(0,1fr)]' : '' }`} 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 new file mode 100644 index 00000000000..47c56eab1fd --- /dev/null +++ b/src/renderer/src/components/sidebar/use-add-repo-hosted-controller.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ReactModule from 'react' + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCallback: unknown>(fn: T) => fn, + useMemo: (factory: () => T) => factory() + } +}) + +const mocks = vi.hoisted(() => ({ + state: { + closeModal: vi.fn(), + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + }, + markOnboardingProjectAdded: vi.fn(() => Promise.resolve()) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state), + { getState: () => mocks.state } + ) +})) + +vi.mock('@/lib/onboarding-project-checklist', () => ({ + markOnboardingProjectAdded: mocks.markOnboardingProjectAdded +})) + +import { useAddRepoHostedController } from './use-add-repo-hosted-controller' + +describe('useAddRepoHostedController', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('falls back to the store closeModal without a hosted controller', () => { + const { closeModal, closeForFolderHandoff, finishProjectAdd } = + useAddRepoHostedController(undefined) + closeModal() + expect(mocks.state.closeModal).toHaveBeenCalledTimes(1) + closeForFolderHandoff() + expect(mocks.state.closeModal).toHaveBeenCalledTimes(2) + expect(finishProjectAdd).toBeUndefined() + }) + + it('closes only the hosted dialog, never the store modal slot', () => { + const onOpenChange = vi.fn() + const { closeModal } = useAddRepoHostedController({ + open: true, + onOpenChange, + onProjectAdded: vi.fn() + }) + closeModal() + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(mocks.state.closeModal).not.toHaveBeenCalled() + }) + + it('folder handoffs close both the hosted dialog and the composer modal', () => { + const onOpenChange = vi.fn() + const { closeForFolderHandoff } = useAddRepoHostedController({ + open: true, + onOpenChange, + onProjectAdded: vi.fn() + }) + // Why: folder/non-git outcomes navigate (folder-workspace activation or + // the confirm-non-git-folder store modal); leaving the composer open + // would hide that navigation behind a stale project selection. + closeForFolderHandoff() + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(mocks.state.closeModal).toHaveBeenCalledTimes(1) + }) + + it('finishProjectAdd closes the hosted dialog and hands the repo to the host', async () => { + const order: string[] = [] + const onOpenChange = vi.fn(() => order.push('close')) + const onProjectAdded = vi.fn(() => { + order.push('added') + }) + const { finishProjectAdd } = useAddRepoHostedController({ + open: true, + onOpenChange, + onProjectAdded + }) + await finishProjectAdd?.('repo-1') + 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('SSH settings navigation closes both hosted dialog and composer modal', () => { + const onOpenChange = vi.fn() + const { handleOpenSshSettings } = useAddRepoHostedController({ + open: true, + onOpenChange, + onProjectAdded: vi.fn() + }) + handleOpenSshSettings() + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(mocks.state.closeModal).toHaveBeenCalledTimes(1) + expect(mocks.state.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'ssh', + repoId: null, + sectionId: 'ssh' + }) + expect(mocks.state.openSettingsPage).toHaveBeenCalledTimes(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 new file mode 100644 index 00000000000..3b26b0a1e4f --- /dev/null +++ b/src/renderer/src/components/sidebar/use-add-repo-hosted-controller.ts @@ -0,0 +1,72 @@ +import { useCallback, useMemo } from 'react' +import { useAppStore } from '@/store' +import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' + +/** Contract a host surface (e.g. the workspace composer modal) passes to + * AddRepoDialog to nest it as a layered dialog instead of the store modal. */ +export type AddRepoDialogHostedController = { + open: boolean + onOpenChange: (open: boolean) => void + /** 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 + /** 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. */ + onCloseAutoFocus?: (event: Event) => void +} + +export function useAddRepoHostedController(hosted: AddRepoDialogHostedController | undefined): { + closeModal: () => void + /** Close for folder/non-git outcomes, which end in folder-workspace + * activation (or the confirm-non-git-folder store modal) instead of a + * 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 + handleOpenSshSettings: () => void +} { + const storeCloseModal = useAppStore((s) => s.closeModal) + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const hostedOnOpenChange = hosted?.onOpenChange + const hostedOnProjectAdded = hosted?.onProjectAdded + // 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( + () => + hostedOnOpenChange && hostedOnProjectAdded + ? async (repoId: string): Promise => { + await markOnboardingProjectAdded('addedRepo') + hostedOnOpenChange(false) + await hostedOnProjectAdded(repoId) + } + : undefined, + [hostedOnOpenChange, hostedOnProjectAdded] + ) + const closeForFolderHandoff = useMemo( + () => + hostedOnOpenChange + ? () => { + hostedOnOpenChange(false) + storeCloseModal() + } + : storeCloseModal, + [hostedOnOpenChange, storeCloseModal] + ) + const handleOpenSshSettings = useCallback((): void => { + closeModal() + // Why: Settings is a full page; in hosted mode the composer modal in the + // activeModal slot would otherwise stay open on top of it. + if (hostedOnOpenChange) { + storeCloseModal() + } + openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' }) + openSettingsPage() + }, [closeModal, hostedOnOpenChange, openSettingsPage, openSettingsTarget, storeCloseModal]) + return { closeModal, closeForFolderHandoff, finishProjectAdd, handleOpenSshSettings } +} 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 c8cb7b97da7..fe16881c7a4 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 @@ -12,11 +12,16 @@ import { finishProjectAddWithDefaultCheckout } from './project-added-default-che 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) => Promise } export function useCompleteGitRepoAdd({ closeModal, - setHideDefaultBranchWorkspace + setHideDefaultBranchWorkspace, + finishProjectAdd }: CompleteGitRepoAddOptions): ( repoId: string, source: AddRepoExistingWorkspaceSource @@ -44,6 +49,10 @@ export function useCompleteGitRepoAdd({ detectedTelemetryTrackedRef.current.add(repoId) track('add_repo_existing_workspaces_detected', existingWorkspaceTelemetry) } + if (finishProjectAdd) { + await finishProjectAdd(repoId, source) + return + } await finishProjectAddWithDefaultCheckout({ repoId, source, @@ -51,6 +60,6 @@ export function useCompleteGitRepoAdd({ setHideDefaultBranchWorkspace }) }, - [closeModal, setHideDefaultBranchWorkspace] + [closeModal, finishProjectAdd, setHideDefaultBranchWorkspace] ) } diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts index cd1cd54e514..aa5a4b9fdbf 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts @@ -78,6 +78,7 @@ function useTestAddRepoNestedImportFlow( nestedGroupName: 'platform', nestedImportScanId: 'scan-1', activeRuntimeEnvironmentId: null, + closeModal: mocks.state.closeModal, fetchWorktrees: vi.fn(), importNestedRepos: vi.fn<() => Promise>(), getNestedRepoRuntimeKind: vi.fn(() => 'local' as const), diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts index eacb421224f..c0a69751814 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts @@ -22,6 +22,7 @@ export function useAddRepoNestedImportFlow({ nestedGroupName, nestedImportScanId, activeRuntimeEnvironmentId, + closeModal, fetchWorktrees, importNestedRepos, getNestedRepoRuntimeKind, @@ -36,6 +37,9 @@ export function useAddRepoNestedImportFlow({ nestedGroupName: string nestedImportScanId: string | null activeRuntimeEnvironmentId: string | null | undefined + /** Why: hosted (composer-nested) mode routes this to closing only the + * nested dialog; store-modal mode routes it to the activeModal slot. */ + closeModal: () => void fetchWorktrees: (repoId: string, options?: { requireAuthoritative?: boolean }) => Promise importNestedRepos: (args: { parentPath: string @@ -260,7 +264,10 @@ export function useAddRepoNestedImportFlow({ try { const state = useAppStore.getState() if (nestedConnectionId) { - state.closeModal() + // Why: the non-git confirm dialog is a store-modal handoff that ends + // in folder-workspace activation; close this add flow (nested dialog + // or store modal) before handing over. + closeModal() state.openModal('confirm-non-git-folder', { folderPath: path, connectionId: nestedConnectionId @@ -274,7 +281,7 @@ export function useAddRepoNestedImportFlow({ return } if (repo) { - useAppStore.getState().closeModal() + closeModal() } } catch (err) { if (gen === nestedImportGenRef.current) { @@ -287,6 +294,7 @@ export function useAddRepoNestedImportFlow({ } }, [ activeRuntimeEnvironmentId, + closeModal, getNestedRepoRuntimeKind, nestedAttemptId, nestedConnectionId, diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 42f2ef4dd5b..1c2e633a67c 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -391,6 +391,9 @@ export type UseComposerStateResult = { submitQuick: (agent: TuiAgent | null) => Promise /** Invoked by the Enter handler to re-check whether submission should fire. */ createDisabled: boolean + /** Selects the repo a nested Add Project flow just added, clearing any + * folder-group target so the composer lands on the new project. */ + selectAddedProjectRepo: (repoId: string) => void } export type InitialWorkspaceRunSeedInput = { @@ -2819,6 +2822,19 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS workspaceHostScope ] ) + const selectAddedProjectRepo = useCallback( + (nextRepoId: string): void => { + // Why: the nested Add Project flow hands back a repo id. Selecting it + // must also clear a folder-group target, whose onRepoChange handler + // only accepts repos inside the selected group. + initialProjectGroupAppliedRef.current = true + setSelectedProjectGroupId(null) + setProjectError(null) + handleRepoChange(nextRepoId) + }, + [handleRepoChange] + ) + const showProjectRequiredError = useCallback((): void => { setProjectError('Choose or add a project before creating a workspace.') requestAnimationFrame(() => { @@ -4440,6 +4456,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS nameInputRef, submit, submitQuick, - createDisabled + createDisabled, + selectAddedProjectRepo } }