import "./SetupWizardModal.css"; import { lazy, Suspense, useState, useCallback, useMemo, useRef, useEffect, type KeyboardEvent } from "react"; import { X, Loader2, CheckCircle, ChevronRight, Sparkles } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { AgentOnboardingSummary, ProjectInfo, ProjectCreateInput } from "../api"; import { createAgent, registerProject, detectWorkspace } from "../api"; import { DirectoryPicker } from "./DirectoryPicker"; import { suggestProjectName } from "../utils/projectDetection"; /* FNXC:TaskPrefix 2026-06-24-19:00: Derive a task prefix from a project name in the browser. Mirrors the logic in @fusion/core's suggestTaskPrefix: strip non-alpha, uppercase, take 2-4 chars, fall back to "FN". Duplicated because @fusion/core is server-only. */ function suggestTaskPrefixFromName(name: string): string { const cleaned = name.replace(/[^a-zA-Z]/g, "").toUpperCase(); if (cleaned.length >= 2 && cleaned.length <= 4) return cleaned; if (cleaned.length > 4) return cleaned.slice(0, 4); return "FN"; } import { useNodes } from "../hooks/useNodes"; import { AgentAvatar } from "./AgentAvatar"; import { ErrorBoundary } from "./ErrorBoundary"; import { AGENT_PRESETS, getPresetById } from "./agent-presets"; import { buildAgentCreatePayload, mapOnboardingSummaryToAgentDraft, mapPresetToAgentDraft, type AgentDraftValues, } from "./agent-presets/agentCreatePayload"; const ExperimentalAgentOnboardingModal = lazy(() => import("./ExperimentalAgentOnboardingModal").then((m) => ({ default: m.ExperimentalAgentOnboardingModal })), ); export interface SetupWizardModalProps { /** Called when first-run setup should enter the registered project. */ onProjectRegistered: (project: ProjectInfo) => void; /** Called when wizard is closed (completed or cancelled) */ onClose?: () => void; /** Enables the existing AI interview entry point for first-agent drafting. */ agentOnboardingEnabled?: boolean; /** When false, register the project and return control to a parent onboarding flow. */ includeAgentStep?: boolean; } type WizardStep = "manual" | "agent" | "complete"; type ManualSetupMode = "existing" | "clone"; type AgentOutcome = "created" | "skipped" | null; interface WizardState { step: WizardStep; manualMode: ManualSetupMode; manualPath: string; manualCloneUrl: string; manualName: string; manualIsolationMode: "in-process" | "child-process"; manualNodeId: string; manualTaskPrefix: string; detectedRepos: string[]; workspaceMode: boolean; isDetectingWorkspace: boolean; registeredProject: ProjectInfo | null; selectedPresetId: string; agentDraft: AgentDraftValues; isCreatingAgent: boolean; isRegistering: boolean; error: string | null; agentError: string | null; agentOutcome: AgentOutcome; } /** * Setup wizard for project registration. * * Provides a focused project-details -> project-agent flow with a directory * picker for selecting the project directory and auto-name suggestion. */ export function SetupWizardModal({ onProjectRegistered, onClose, agentOnboardingEnabled = false, includeAgentStep = true, }: SetupWizardModalProps) { const { t } = useTranslation("app"); const helpUrl = "https://discord.gg/ksrfuy7WYR"; /* FNXC:Onboarding 2026-06-22-03:11: New-project setup must collect project details first, then offer a project-specific persistent agent after registration, defaulting to the CEO preset while still letting users choose another template or skip creation. The AI interview entry point is feature-flagged by `agentOnboardingEnabled`; preset creation and skip remain available without it. FNXC:Onboarding 2026-06-22-05:16: Brand-new onboarding already has its own Agent step after AI, GitHub, and Project setup. When this wizard is opened as that Project sub-flow, register the project and return immediately so users do not see two agent prompts. */ const ceoPreset = useMemo( () => getPresetById("ceo") ?? AGENT_PRESETS[0]!, [], ); const [isOpen, setIsOpen] = useState(true); const [state, setState] = useState(() => ({ step: "manual", manualMode: "existing", manualPath: "", manualCloneUrl: "", manualName: "", manualIsolationMode: "in-process", manualNodeId: "", manualTaskPrefix: "", detectedRepos: [], workspaceMode: false, isDetectingWorkspace: false, registeredProject: null, selectedPresetId: ceoPreset.id, agentDraft: mapPresetToAgentDraft(ceoPreset), isCreatingAgent: false, isRegistering: false, error: null, agentError: null, agentOutcome: null, })); const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const [isInterviewOpen, setIsInterviewOpen] = useState(false); const agentErrorRef = useRef(null); const { nodes, loading: nodesLoading } = useNodes(); const localNodeId = nodes.find((n) => n.type === "local")?.id; const handleClose = useCallback(() => { setIsOpen(false); onClose?.(); }, [onClose]); const handleFinish = useCallback(() => { if (state.registeredProject) { onProjectRegistered(state.registeredProject); return; } handleClose(); }, [handleClose, onProjectRegistered, state.registeredProject]); useEffect(() => { if (state.agentError) { agentErrorRef.current?.focus(); } }, [state.agentError]); const detectWorkspaceRequestId = useRef(0); const handlePathChange = useCallback((path: string) => { setState((prev) => { const updates: Partial = { manualPath: path, detectedRepos: [], workspaceMode: false }; // Auto-suggest name when path changes and name is empty or was previously auto-suggested if (path && (!prev.manualName || prev.manualName === suggestProjectName(prev.manualPath))) { updates.manualName = suggestProjectName(path); } // Auto-suggest prefix when name changes and prefix is empty or was previously auto-suggested const suggestedName = updates.manualName ?? prev.manualName; if (suggestedName && (!prev.manualTaskPrefix || prev.manualTaskPrefix === suggestTaskPrefixFromName(suggestProjectName(prev.manualPath)))) { updates.manualTaskPrefix = suggestTaskPrefixFromName(suggestedName); } return { ...prev, ...updates }; }); /* FNXC:Workspace 2026-06-24-21:00: Detect workspace sub-repos only in existing-directory mode (clone mode creates a fresh directory with a single repo). A monotonic request ID guards against stale responses overwriting state from a newer path entry (race condition on rapid typing). */ if (state.manualMode === "existing" && path.trim() && path.trim() !== "/") { const requestId = ++detectWorkspaceRequestId.current; setState((prev) => ({ ...prev, isDetectingWorkspace: true })); detectWorkspace(path.trim()) .then((result) => { if (requestId !== detectWorkspaceRequestId.current) return; setState((prev) => ({ ...prev, isDetectingWorkspace: false, detectedRepos: result.repos, workspaceMode: result.isWorkspace, })); }) .catch(() => { if (requestId !== detectWorkspaceRequestId.current) return; setState((prev) => ({ ...prev, isDetectingWorkspace: false })); }); } }, [state.manualMode]); const handleManualRegister = useCallback(async () => { const trimmedPath = state.manualPath.trim(); const trimmedName = state.manualName.trim(); const trimmedCloneUrl = state.manualCloneUrl.trim(); if (!trimmedPath || !trimmedName) return; if (state.manualMode === "clone" && !trimmedCloneUrl) return; setState((prev) => ({ ...prev, isRegistering: true, error: null })); try { const input: ProjectCreateInput = { name: trimmedName, path: trimmedPath, isolationMode: state.manualIsolationMode, nodeId: state.manualNodeId || undefined, cloneUrl: state.manualMode === "clone" ? trimmedCloneUrl : undefined, workspaceMode: state.workspaceMode, taskPrefix: state.manualTaskPrefix.trim() || undefined, }; const result = await registerProject(input); if (!includeAgentStep) { setState((prev) => ({ ...prev, isRegistering: false, })); onProjectRegistered(result); return; } setState((prev) => ({ ...prev, step: "agent", registeredProject: result, isRegistering: false, })); } catch (err) { setState((prev) => ({ ...prev, isRegistering: false, error: err instanceof Error ? err.message : "Failed to register project", })); } }, [includeAgentStep, onProjectRegistered, state.manualPath, state.manualName, state.manualCloneUrl, state.manualMode, state.manualIsolationMode, state.manualNodeId, state.workspaceMode, state.manualTaskPrefix]); const handlePresetSelect = useCallback((presetId: string) => { const preset = getPresetById(presetId); if (!preset) return; setState((prev) => ({ ...prev, selectedPresetId: preset.id, agentDraft: mapPresetToAgentDraft(preset), agentError: null, })); }, []); const handlePresetKeyDown = useCallback((event: KeyboardEvent, presetId: string) => { const currentIndex = AGENT_PRESETS.findIndex((preset) => preset.id === presetId); if (currentIndex < 0) return; const lastIndex = AGENT_PRESETS.length - 1; let nextIndex: number | null = null; if (event.key === "ArrowDown" || event.key === "ArrowRight") { nextIndex = currentIndex === lastIndex ? 0 : currentIndex + 1; } else if (event.key === "ArrowUp" || event.key === "ArrowLeft") { nextIndex = currentIndex === 0 ? lastIndex : currentIndex - 1; } else if (event.key === "Home") { nextIndex = 0; } else if (event.key === "End") { nextIndex = lastIndex; } if (nextIndex === null) return; event.preventDefault(); const nextPreset = AGENT_PRESETS[nextIndex]; handlePresetSelect(nextPreset.id); requestAnimationFrame(() => { document.querySelector(`[data-agent-preset-id="${nextPreset.id}"]`)?.focus(); }); }, [handlePresetSelect]); const handleApplyAgentDraft = useCallback((draft: AgentOnboardingSummary) => { setState((prev) => ({ ...prev, selectedPresetId: "", agentDraft: mapOnboardingSummaryToAgentDraft(draft), agentError: null, })); }, []); const handleCreateFirstAgent = useCallback(async () => { if (!state.registeredProject || !state.agentDraft.name.trim()) return; setState((prev) => ({ ...prev, isCreatingAgent: true, agentError: null })); try { await createAgent(buildAgentCreatePayload(state.agentDraft), state.registeredProject.id); setState((prev) => ({ ...prev, step: "complete", isCreatingAgent: false, agentOutcome: "created", })); } catch (err) { setState((prev) => ({ ...prev, isCreatingAgent: false, agentError: err instanceof Error ? err.message : t("setup.firstAgentCreateError", "Failed to create agent"), })); } }, [state.agentDraft, state.registeredProject, t]); const handleSkipAgent = useCallback(() => { setState((prev) => ({ ...prev, step: "complete", agentError: null, agentOutcome: "skipped", })); }, []); if (!isOpen) return null; const isExistingMode = state.manualMode === "existing"; const isCloneMode = state.manualMode === "clone"; const hasPath = state.manualPath.trim().length > 0; const hasName = state.manualName.trim().length > 0; const hasCloneUrl = state.manualCloneUrl.trim().length > 0; const isRegisterDisabled = state.isRegistering || !hasPath || !hasName || (isCloneMode && !hasCloneUrl); const selectedPreset = state.selectedPresetId ? getPresetById(state.selectedPresetId) : undefined; const isAgentActionDisabled = state.isCreatingAgent; /* FNXC:Onboarding 2026-06-22-06:03: AI-generated agent drafts are custom and should not appear selected as a template, but the template radiogroup still needs one tabbable item for keyboard users. */ const agentPresetTabStopId = state.selectedPresetId || ceoPreset.id; /* FNXC:Onboarding 2026-06-22-05:37: The optional project-agent step needs more horizontal room than project details so templates and preview can be compared side by side. Keep the wider modal scoped to the agent step so the initial project form stays compact. */ const modalClassName = `modal setup-wizard-modal${state.step === "agent" ? " setup-wizard-modal--agent" : ""}`; return (
{/* Header */}
{t("setup.brandName", "Fusion")}

{state.step === "manual" && t("setup.welcomeToFusion", "Welcome to Fusion")} {state.step === "agent" && t("setup.firstAgentTitle", "Create your first agent")} {state.step === "complete" && t("setup.setupCompleteTitle", "Setup Complete!")}

{state.step !== "complete" && state.step !== "agent" && ( )}
{/* Content */}
{/* Manual Step */} {state.step === "manual" && (
setState((prev) => ({ ...prev, manualName: e.target.value })) } placeholder={t("setup.projectNamePlaceholder", "my-project")} />

{isCloneMode ? t("setup.projectNameHintClone", "By default this follows the destination folder name unless you edit it.") : t("setup.projectNameHintExisting", "By default this follows the selected directory name unless you edit it.")}

{isCloneMode ? t("setup.clonePathHint", "Select or type an absolute destination path. Fusion will clone into this directory.") : t("setup.projectPathHint", "Select or type the absolute path to your project")}

{/* FNXC:Workspace 2026-06-24-19:00: Workspace mode detection: when the selected directory contains git sub-repos, show a checkbox letting the user opt into workspace mode. In workspace mode, tasks run per-sub-repo and no git repo is created at the root. */} {isExistingMode && state.manualPath.trim() && (
{state.isDetectingWorkspace && (

{t("setup.detectingWorkspace", "Detecting sub-repositories...")}

)} {!state.isDetectingWorkspace && state.detectedRepos.length > 0 && (

{t("setup.detectedRepos", "Found {{count}} repositories:", { count: state.detectedRepos.length })} {" "} {state.detectedRepos.join(", ")}

)} {!state.isDetectingWorkspace && state.detectedRepos.length === 0 && state.workspaceMode === false && state.manualPath.trim() && (

{t("setup.noSubReposDetected", "No sub-repositories detected. Enable if this is a multi-repo workspace.")}

)}
)} {/* FNXC:TaskPrefix 2026-06-24-19:00: Task prefix field: auto-derived from the project name. The prefix is used for task IDs (e.g. "MYPR-1"). Users can override it. */}
setState((prev) => ({ ...prev, manualTaskPrefix: e.target.value.toUpperCase() }))} placeholder={suggestTaskPrefixFromName(state.manualName || "FN")} maxLength={5} />

{t("setup.taskPrefixHint", "Used for task IDs (e.g. \"{{prefix}}-1\"). Derived from project name.", { prefix: state.manualTaskPrefix || "FN" })}

{showAdvancedSettings && (
{t("setup.setupMode", "Setup Mode")}
{isCloneMode && (
setState((prev) => ({ ...prev, manualCloneUrl: e.target.value }))} placeholder={t("setup.repositoryUrlPlaceholder", "https://github.com/owner/repo.git")} />

{t("setup.cloneGitHint", "Fusion will run git clone into the destination directory, then register that cloned folder.")}

)}
{t("setup.runtimeNode", "Runtime Node")}
)}
{state.error && (
{state.error}
)}
)} {/* FNXC:Onboarding 2026-06-22-03:11: First-run setup asks for an optional persistent coordinating agent after project registration. Users can skip it because task creation and task execution do not require an assigned persistent agent; Fusion automatically spawns temporary planning, execution, review, and merge agents for task work. */} {state.step === "agent" && (

{t("setup.firstAgentIntro", "Agents are optional. Fusion can build tasks without one by starting temporary agents for planning, coding, review, and merge. Create an agent only if you want help coordinating tasks and direction.")}

{t("setup.firstAgentTemplates", "Templates")}
{AGENT_PRESETS.map((preset) => { const selected = state.selectedPresetId === preset.id; return ( ); })}
{t("setup.firstAgentPreview", "Preview")}

{state.agentDraft.name || t("setup.firstAgentDraftName", "Draft agent")}

{state.agentDraft.title || selectedPreset?.title || t("setup.firstAgentCustomDraft", "Custom agent draft")}

{t("agents.fieldRole", "Role")}
{state.agentDraft.role}
{t("agents.fieldInstructionsText", "Inline Instructions")}
{state.agentDraft.instructionsText || t("setup.firstAgentNoInstructions", "No inline instructions yet")}
{agentOnboardingEnabled && ( )}
{state.agentError && (
{state.agentError}
)}
)} {/* Complete Step */} {state.step === "complete" && (
{/* Footer */}
{t("setup.needHelp", "Need help?")} {state.step === "manual" && ( )} {state.step === "agent" && ( <> )} {state.step === "complete" && ( )}
{agentOnboardingEnabled && isInterviewOpen && ( {t("setup.firstAgentInterviewLoadError", "AI interview could not load. You can still create an agent from a template or skip this step.")}
)} > {t("setup.firstAgentInterviewLoading", "Loading AI Interview...")}
)} > setIsInterviewOpen(false)} onUseDraft={handleApplyAgentDraft} projectId={state.registeredProject?.id} existingAgents={[]} mode="create" /> )}
); }