mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(create-worktree): keep composer open when adding a project from the dialog (#9397)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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<Record<SshConnectionStatus, string>> = {
|
||||
@@ -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<HTMLTextAreaElement>) => {
|
||||
const text = event.clipboardData.getData('text/plain')
|
||||
const byteLengthMeasurement = measureTextControlPasteByteLength(text, {
|
||||
|
||||
@@ -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<void> => {
|
||||
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<AddRepoDialogHostedController>(
|
||||
() => ({
|
||||
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}
|
||||
/>
|
||||
<AgentSettingsDialog open={agentSettingsOpen} onOpenChange={setAgentSettingsOpen} />
|
||||
{addProjectMounted ? (
|
||||
<Suspense fallback={null}>
|
||||
<HostedAddRepoDialog hosted={addProjectController} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<AddRepoDialogStep>('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}
|
||||
>
|
||||
<AddRepoDialogStepContent
|
||||
@@ -365,11 +382,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
||||
setRemoteError(null)
|
||||
}}
|
||||
onAddRemoteRepo={handleAddRemoteRepo}
|
||||
onOpenSshSettings={() => {
|
||||
closeModal()
|
||||
openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' })
|
||||
openSettingsPage()
|
||||
}}
|
||||
onOpenSshSettings={handleOpenSshSettings}
|
||||
onConnectTarget={handleConnectTarget}
|
||||
onStopRemoteNestedScan={stopRemoteNestedScan}
|
||||
onCloneUrlChange={(value) => {
|
||||
|
||||
@@ -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 (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
className={`min-w-0 overflow-hidden sm:max-w-lg [&>*]:min-w-0 ${
|
||||
step === 'nested' ? 'max-h-[calc(100vh-2rem)] grid-rows-[auto_auto_minmax(0,1fr)]' : ''
|
||||
}`}
|
||||
|
||||
@@ -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<typeof ReactModule>()
|
||||
return {
|
||||
...actual,
|
||||
useCallback: <T extends (...args: never[]) => unknown>(fn: T) => fn,
|
||||
useMemo: <T>(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)
|
||||
})
|
||||
})
|
||||
@@ -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<void>
|
||||
/** 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<void>) | 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<void> => {
|
||||
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 }
|
||||
}
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
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]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ function useTestAddRepoNestedImportFlow(
|
||||
nestedGroupName: 'platform',
|
||||
nestedImportScanId: 'scan-1',
|
||||
activeRuntimeEnvironmentId: null,
|
||||
closeModal: mocks.state.closeModal,
|
||||
fetchWorktrees: vi.fn(),
|
||||
importNestedRepos: vi.fn<() => Promise<ProjectGroupImportResult | null>>(),
|
||||
getNestedRepoRuntimeKind: vi.fn(() => 'local' as const),
|
||||
|
||||
@@ -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<unknown>
|
||||
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,
|
||||
|
||||
@@ -391,6 +391,9 @@ export type UseComposerStateResult = {
|
||||
submitQuick: (agent: TuiAgent | null) => Promise<void>
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user