import { useState, useEffect, useCallback, useRef } from "react"; import { X, Loader2, CheckCircle, Key, Zap, GitPullRequest, Rocket, Plus } from "lucide-react"; import type { AuthProvider, ModelInfo } from "../api"; import { fetchAuthStatus, fetchGlobalSettings, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, updateGlobalSettings, } from "../api"; import type { ToastType } from "../hooks/useToast"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { getOnboardingState, saveOnboardingState, clearOnboardingState, markOnboardingCompleted, type OnboardingStep, } from "./model-onboarding-state"; import type { SectionId } from "./SettingsModal"; export interface ModelOnboardingModalProps { /** Called when onboarding is complete or dismissed */ onComplete: () => void; /** Toast helper */ addToast: (message: string, type?: ToastType) => void; /** Optional callback when user wants to open new task creation */ onOpenNewTask?: () => void; /** Optional callback when user wants to open GitHub import */ onOpenGitHubImport?: () => void; } /** * Multi-step onboarding modal that guides users through: * 1. AI Setup - Provider credential setup (OAuth login or API key entry) and default model selection * 2. GitHub (Optional) - GitHub connection status and login * 3. First Task - CTA to create first task or import from GitHub * * Dismissing the modal marks onboarding as complete to prevent repeated popups. */ export function ModelOnboardingModal({ onComplete, addToast, onOpenNewTask, onOpenGitHubImport, }: ModelOnboardingModalProps) { // Initialize from persisted state if available (allows resume from last step) const persistedState = getOnboardingState(); const initialStep: OnboardingStep = persistedState && persistedState.currentStep !== "complete" ? persistedState.currentStep as OnboardingStep : "ai-setup"; const [isOpen, setIsOpen] = useState(true); const [step, setStep] = useState(initialStep); const [authProviders, setAuthProviders] = useState([]); const [authLoading, setAuthLoading] = useState(true); const [authActionInProgress, setAuthActionInProgress] = useState(null); const [availableModels, setAvailableModels] = useState([]); const [selectedModel, setSelectedModel] = useState(""); const [saving, setSaving] = useState(false); const [apiKeyInputs, setApiKeyInputs] = useState>({}); const [apiKeyErrors, setApiKeyErrors] = useState>({}); const pollIntervalRef = useRef | null>(null); // Step definitions for progress indicator const steps = [ { key: "ai-setup" as const, label: "AI Setup" }, { key: "github" as const, label: "GitHub" }, { key: "first-task" as const, label: "First Task" }, ]; // Get current step index for progress indicator const currentStepIndex = steps.findIndex((s) => s.key === step); // Persist step state whenever it changes (for resume functionality) useEffect(() => { if (step !== "complete") { saveOnboardingState(step); } }, [step]); // Load auth providers const loadAuthStatus = useCallback(async () => { try { const { providers } = await fetchAuthStatus(); setAuthProviders(providers); } catch { // Silently fail } }, []); // Load models const loadModels = useCallback(async () => { try { const response = await fetchModels(); setAvailableModels(response.models); } catch { // Silently fail } }, []); // Load global settings to hydrate saved default model (for reopen flow) const loadGlobalSettings = useCallback(async () => { try { const globalSettings = await fetchGlobalSettings(); // If a default model is configured, pre-select it if (globalSettings.defaultProvider && globalSettings.defaultModelId) { const defaultModelValue = `${globalSettings.defaultProvider}/${globalSettings.defaultModelId}`; setSelectedModel(defaultModelValue); } } catch { // Silently fail - onboarding still works without hydration } }, []); // Initial data load useEffect(() => { Promise.all([loadAuthStatus(), loadModels(), loadGlobalSettings()]).finally(() => setAuthLoading(false), ); }, [loadAuthStatus, loadModels, loadGlobalSettings]); // Check if we have GitHub provider const githubProvider = authProviders.find((p) => p.id === "github"); const hasGithubProvider = !!githubProvider; const isGithubAuthenticated = githubProvider?.authenticated ?? false; // Cleanup polling on unmount useEffect(() => { return () => { if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); } }; }, []); // Navigate to next step const handleNext = useCallback(() => { if (step === "ai-setup") { setStep("github"); } else if (step === "github") { setStep("first-task"); } }, [step]); // Navigate to previous step const handleBack = useCallback(() => { if (step === "github") { setStep("ai-setup"); } else if (step === "first-task") { setStep("github"); } }, [step]); // OAuth login handler const handleLogin = useCallback( async (providerId: string) => { setAuthActionInProgress(providerId); try { const { url } = await loginProvider(providerId); window.open(url, "_blank"); // Poll for auth completion pollIntervalRef.current = setInterval(async () => { try { const { providers } = await fetchAuthStatus(); setAuthProviders(providers); const provider = providers.find((p) => p.id === providerId); if (provider?.authenticated) { if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); pollIntervalRef.current = null; } setAuthActionInProgress(null); addToast("Login successful", "success"); } } catch { // Continue polling } }, 2000); } catch (err: unknown) { addToast( err instanceof Error ? err.message : "Login failed", "error", ); setAuthActionInProgress(null); } }, [addToast], ); // API key save handler const handleSaveApiKey = useCallback( async (providerId: string) => { const key = apiKeyInputs[providerId]?.trim(); if (!key) { setApiKeyErrors((prev) => ({ ...prev, [providerId]: "API key is required", })); return; } setAuthActionInProgress(providerId); setApiKeyErrors((prev) => { const next = { ...prev }; delete next[providerId]; return next; }); try { await saveApiKey(providerId, key); await loadAuthStatus(); setApiKeyInputs((prev) => { const next = { ...prev }; delete next[providerId]; return next; }); addToast("API key saved", "success"); } catch (err: unknown) { addToast( err instanceof Error ? err.message : "Failed to save API key", "error", ); } finally { setAuthActionInProgress(null); } }, [apiKeyInputs, addToast, loadAuthStatus], ); // API key clear handler const handleClearApiKey = useCallback( async (providerId: string) => { setAuthActionInProgress(providerId); try { await clearApiKey(providerId); await loadAuthStatus(); addToast("API key removed", "success"); } catch (err: unknown) { addToast( err instanceof Error ? err.message : "Failed to clear API key", "error", ); } finally { setAuthActionInProgress(null); } }, [addToast, loadAuthStatus], ); // Logout handler (for OAuth providers that are authenticated) const handleLogout = useCallback( async (providerId: string) => { setAuthActionInProgress(providerId); try { await logoutProvider(providerId); await loadAuthStatus(); addToast("Logged out", "success"); } catch (err: unknown) { addToast( err instanceof Error ? err.message : "Logout failed", "error", ); } finally { setAuthActionInProgress(null); } }, [addToast, loadAuthStatus], ); // Handle model selection from CustomModelDropdown const handleModelSelect = useCallback((value: string) => { setSelectedModel(value); }, []); // Complete onboarding const handleComplete = useCallback(async () => { setSaving(true); try { const updates: Record = { modelOnboardingComplete: true, }; // If a model was selected, persist it as the default if (selectedModel) { // Parse the provider/modelId format from CustomModelDropdown const slashIdx = selectedModel.indexOf("/"); const provider = slashIdx !== -1 ? selectedModel.slice(0, slashIdx) : undefined; const modelId = slashIdx !== -1 ? selectedModel.slice(slashIdx + 1) : selectedModel; const model = availableModels.find((m) => m.id === modelId); if (model) { updates.defaultProvider = model.provider; updates.defaultModelId = model.id; } else if (provider && modelId) { // Fallback: use parsed values even if not in the model list updates.defaultProvider = provider; updates.defaultModelId = modelId; } } await updateGlobalSettings(updates); setStep("complete"); // Mark onboarding as completed (preserves state for completion timestamp) markOnboardingCompleted(); } catch (err: unknown) { addToast( err instanceof Error ? err.message : "Failed to save settings", "error", ); } finally { setSaving(false); } }, [selectedModel, availableModels, addToast]); // Handle first task CTA - mark complete, close modal, then open new task const handleOpenNewTask = useCallback(async () => { // First complete the onboarding setSaving(true); try { const updates: Record = { modelOnboardingComplete: true, }; // If a model was selected, persist it as the default if (selectedModel) { const slashIdx = selectedModel.indexOf("/"); const provider = slashIdx !== -1 ? selectedModel.slice(0, slashIdx) : undefined; const modelId = slashIdx !== -1 ? selectedModel.slice(slashIdx + 1) : selectedModel; const model = availableModels.find((m) => m.id === modelId); if (model) { updates.defaultProvider = model.provider; updates.defaultModelId = model.id; } else if (provider && modelId) { updates.defaultProvider = provider; updates.defaultModelId = modelId; } } await updateGlobalSettings(updates); // Mark onboarding as completed (preserves state for completion timestamp) markOnboardingCompleted(); } catch { // Best-effort: continue even if save fails } finally { setSaving(false); } // Close modal and trigger callback setIsOpen(false); onComplete(); onOpenNewTask?.(); }, [selectedModel, availableModels, onComplete, onOpenNewTask]); // Handle GitHub import CTA - mark complete, close modal, then open GitHub import const handleOpenGitHubImport = useCallback(async () => { // First complete the onboarding setSaving(true); try { const updates: Record = { modelOnboardingComplete: true, }; // If a model was selected, persist it as the default if (selectedModel) { const slashIdx = selectedModel.indexOf("/"); const provider = slashIdx !== -1 ? selectedModel.slice(0, slashIdx) : undefined; const modelId = slashIdx !== -1 ? selectedModel.slice(slashIdx + 1) : selectedModel; const model = availableModels.find((m) => m.id === modelId); if (model) { updates.defaultProvider = model.provider; updates.defaultModelId = model.id; } else if (provider && modelId) { updates.defaultProvider = provider; updates.defaultModelId = modelId; } } await updateGlobalSettings(updates); // Mark onboarding as completed (preserves state for completion timestamp) markOnboardingCompleted(); } catch { // Best-effort: continue even if save fails } finally { setSaving(false); } // Close modal and trigger callback setIsOpen(false); onComplete(); onOpenGitHubImport?.(); }, [selectedModel, availableModels, onComplete, onOpenGitHubImport]); // Dismiss without completing (still marks onboarding complete) const handleDismiss = useCallback(async () => { setSaving(true); try { await updateGlobalSettings({ modelOnboardingComplete: true }); } catch { // Best-effort: still close even if save fails } setIsOpen(false); onComplete(); }, [onComplete]); // Close from the completion step const handleFinish = useCallback(() => { setIsOpen(false); onComplete(); }, [onComplete]); if (!isOpen) return null; const oauthProviders = authProviders.filter( (p) => !p.type || p.type === "oauth", ); const apiKeyProviders = authProviders.filter((p) => p.type === "api_key"); // Filter out GitHub from AI providers list const aiOauthProviders = oauthProviders.filter((p) => p.id !== "github"); const aiApiKeyProviders = apiKeyProviders.filter((p) => p.id !== "github"); return (
{/* Header */}

{step === "ai-setup" && ( <> Set Up AI )} {step === "github" && ( <> Connect GitHub )} {step === "first-task" && ( <> Create Your First Task )} {step === "complete" && ( <> All Set! )}

{step !== "complete" && ( )}
{/* Step indicator - 3 progress steps + complete */}
{steps.map((s, index) => (
{index > 0 && (
)}
index ? "done" : ""}`} > {currentStepIndex > index ? ( ) : ( index + 1 )} {s.label}
))}
{/* Content */}
{step === "ai-setup" && (

Connect an AI provider and choose a default model. You can authenticate via OAuth or enter an API key.

{authLoading ? (
Loading providers…
) : authProviders.length === 0 ? (
No AI providers are configured. Please check your Fusion configuration.
) : ( <> {/* OAuth Providers */} {aiOauthProviders.map((provider) => (
{provider.name} {provider.authenticated ? "✓ Authenticated" : "✗ Not authenticated"}
{authActionInProgress === provider.id ? ( ) : provider.authenticated ? ( ) : ( )}
))} {/* API Key Providers */} {aiApiKeyProviders.map((provider) => (
{provider.name} {provider.authenticated ? "✓ Key saved" : "✗ No API key"}
{provider.authenticated ? ( ) : (
setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value, })) } onKeyDown={(e) => { if (e.key === "Enter") { handleSaveApiKey(provider.id); } }} data-testid={`onboarding-apikey-input-${provider.id}`} />
)} {apiKeyErrors[provider.id] && ( {apiKeyErrors[provider.id]} )}
))} )} {/* Model Selection */}

Default Model (Optional)

Select a default model for AI tasks. You can change this later in Settings.

{availableModels.length === 0 ? (
No models available. Please configure a provider first.
) : (
)} {selectedModel && (
Selected:{" "} {availableModels.find((m) => m.id === selectedModel) ?.name ?? selectedModel}
)}
)} {step === "github" && (

Connect GitHub to import issues and manage pull requests. This is optional — you can still use Fusion without GitHub.

{!hasGithubProvider ? (

GitHub integration is not configured. You can set it up later in Settings → Authentication.

) : (
GitHub {isGithubAuthenticated ? "✓ Connected" : "✗ Not connected"}
{authActionInProgress === "github" ? ( ) : isGithubAuthenticated ? ( ) : ( )}
)}
)} {step === "first-task" && (

You're all set! What would you like to do first?

You can always create tasks later from the board or use{" "} fn task create from the CLI.

)} {step === "complete" && (

You're ready to start using Fusion! Check out the dashboard to create your first task.

)}
{/* Footer */}
{step === "ai-setup" && ( <> )} {step === "github" && ( <> )} {step === "first-task" && ( <> )} {step === "complete" && ( )}
); } import type { ProjectInfo } from "../api"; import type { ColorTheme, Column, MergeResult, Task, ThemeMode } from "@fusion/core"; import type { UseProjectActionsResult } from "../hooks/useProjectActions"; import type { ModalManager } from "../hooks/useModalManager"; import type { UseTaskHandlersResult } from "../hooks/useTaskHandlers"; import type { Toast } from "../hooks/useToast"; import { ModalErrorBoundary } from "./ErrorBoundary"; import { TaskDetailModal } from "./TaskDetailModal"; import { SettingsModal } from "./SettingsModal"; import { GitHubImportModal } from "./GitHubImportModal"; import { PlanningModeModal } from "./PlanningModeModal"; import { SubtaskBreakdownModal } from "./SubtaskBreakdownModal"; import { TerminalModal } from "./TerminalModal"; import { ScriptsModal } from "./ScriptsModal"; import { FileBrowserModal } from "./FileBrowserModal"; import { UsageIndicator } from "./UsageIndicator"; import { ScheduledTasksModal } from "./ScheduledTasksModal"; import { NewTaskModal } from "./NewTaskModal"; import { ActivityLogModal } from "./ActivityLogModal"; import { GitManagerModal } from "./GitManagerModal"; import { WorkflowStepManager } from "./WorkflowStepManager"; import { AgentListModal } from "./AgentListModal"; import { SetupWizardModal } from "./SetupWizardModal"; import { ToastContainer } from "./ToastContainer"; interface AppModalsProps { projectId?: string; tasks: Task[]; projects: ProjectInfo[]; currentProject: ProjectInfo | null; addToast: (message: string, type?: ToastType) => void; toasts: Toast[]; removeToast: (id: number) => void; modalManager: ModalManager; projectActions: Pick; taskHandlers: Pick; taskOperations: { moveTask: (taskId: string, column: Column, position?: number) => Promise; deleteTask: (taskId: string) => Promise; mergeTask: (taskId: string) => Promise; retryTask: (taskId: string) => Promise; duplicateTask: (taskId: string) => Promise; }; deepLink: { handleDetailClose: () => void; }; settings: { githubTokenConfigured: boolean; themeMode: ThemeMode; colorTheme: ColorTheme; setThemeMode: (mode: ThemeMode) => void; setColorTheme: (theme: ColorTheme) => void; }; /** Optional override for the settings modal close handler. When provided, this is called instead of modalManager.closeSettings. */ onSettingsClose?: () => void; } export function AppModals({ projectId, tasks, projects, currentProject, addToast, toasts, removeToast, modalManager, projectActions, taskHandlers, taskOperations, deepLink, settings, onSettingsClose, }: AppModalsProps) { // Use the override handler if provided, otherwise fall back to modalManager.closeSettings const handleSettingsClose = onSettingsClose ?? modalManager.closeSettings; // Handlers for onboarding CTAs const handleOpenNewTask = useCallback(() => { modalManager.openNewTask(); }, [modalManager]); const handleOpenGitHubImport = useCallback(() => { modalManager.openGitHubImport(); }, [modalManager]); return ( <> {modalManager.detailTask && ( )} {modalManager.settingsOpen && ( )} {modalManager.filesOpen && ( )} {modalManager.schedulesOpen && ( )} { const task = tasks.find((candidate) => candidate.id === taskId); if (task) { modalManager.openDetailTask(task); } }} /> {modalManager.setupWizardOpen && ( )} {modalManager.modelOnboardingOpen && ( )} ); } export interface UseAuthOnboardingOptions { projectId?: string; openModelOnboarding: () => void; openSettings: (section?: SectionId) => void; } /** * Runs auth/onboarding checks and opens the appropriate setup modal. * * This hook implements a one-shot guard: the auto-trigger logic runs at most * once per hook instance (regardless of effect re-runs due to dependency changes). * This prevents repeat auto-opens on incidental rerenders or project context churn. * * Trigger behavior: * - First-run (onboarding incomplete): opens model onboarding wizard * - Completed onboarding + unauthenticated providers: opens Settings → Authentication * - Already configured: no auto-open */ export function useAuthOnboarding({ projectId, openModelOnboarding, openSettings, }: UseAuthOnboardingOptions): void { // One-shot guard: prevents the auto-trigger logic from running more than once // per hook instance, even if the effect re-runs due to dependency changes. const hasTriggeredRef = useRef(false); useEffect(() => { // Skip if we've already triggered (one-shot guard) if (hasTriggeredRef.current) return; // Mark as triggered immediately to prevent any race condition on re-runs hasTriggeredRef.current = true; let shouldOpenOnboarding = false; let shouldOpenSettings = false; fetchAuthStatus() .then(({ providers }) => { const hasAuthenticatedProvider = providers.some((provider) => provider.authenticated); const needsSetup = providers.length > 0 && !hasAuthenticatedProvider; if (needsSetup || (providers.length > 0 && hasAuthenticatedProvider)) { return fetchGlobalSettings() .then((globalSettings) => { const hasDefaultModel = !!( globalSettings.defaultProvider && globalSettings.defaultModelId ); // Explicit first-run detection: onboarding is incomplete when // modelOnboardingComplete is false or undefined const onboardingIncomplete = globalSettings.modelOnboardingComplete === false || globalSettings.modelOnboardingComplete === undefined; const setupIncomplete = !hasAuthenticatedProvider || !hasDefaultModel; if (onboardingIncomplete && setupIncomplete) { shouldOpenOnboarding = true; } else if (!hasAuthenticatedProvider) { // Completed onboarding but no authenticated provider → fallback // to Settings Authentication section shouldOpenSettings = true; } }); } }) .then(() => { // Execute after the promise chain resolves if (shouldOpenOnboarding) { openModelOnboarding(); } else if (shouldOpenSettings) { openSettings("authentication"); } }) .catch(() => { // Fail silently - non-blocking behavior preserves dashboard usability. // Onboarding can be manually triggered later via Settings if needed. }); }, [projectId, openModelOnboarding, openSettings]); }