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, getStepData, 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; } /** Outcome states for OAuth login attempts */ export type LoginOutcome = "pending" | "success" | "timeout" | "failed" | "cancelled"; /** Maximum number of poll cycles before timing out (150 × 2s = 5 minutes) */ const MAX_POLL_CYCLES = 150; /** * 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"; // Restore completed steps from persisted state const persistedCompletedSteps = persistedState?.completedSteps ?? []; const [isOpen, setIsOpen] = useState(true); const [step, setStep] = useState(initialStep); const [completedSteps, setCompletedSteps] = useState(persistedCompletedSteps); 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); const [loginOutcomes, setLoginOutcomes] = useState>({}); const pollCountRef = useRef(0); // 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, { completedSteps }); } }, [step, completedSteps]); // 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]); // Restore login outcomes from persisted state on mount useEffect(() => { const persistedStepData = getStepData("ai-setup"); if (persistedStepData?.loginOutcomes) { const persistedOutcomes = persistedStepData.loginOutcomes as Record; // Filter out stale "pending" entries from previous sessions const filteredOutcomes: Record = {}; for (const [providerId, outcome] of Object.entries(persistedOutcomes)) { if (outcome !== "pending") { filteredOutcomes[providerId] = outcome; } } if (Object.keys(filteredOutcomes).length > 0) { setLoginOutcomes(filteredOutcomes); } } }, []); // Helper to persist login outcome to onboarding state const persistLoginOutcome = useCallback((providerId: string, outcome: LoginOutcome) => { saveOnboardingState(step, { completedSteps, stepData: { "ai-setup": { loginOutcomes: { [providerId]: outcome, }, }, }, }); }, [step, completedSteps]); // Persist terminal login outcomes whenever they transition useEffect(() => { const terminalOutcomes = Object.entries(loginOutcomes).filter( ([_, outcome]) => outcome !== "pending" ); for (const [providerId, outcome] of terminalOutcomes) { persistLoginOutcome(providerId, outcome); } }, [loginOutcomes, persistLoginOutcome]); // 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(() => { // Mark current step as completed before moving forward setCompletedSteps(prev => [...new Set([...prev, step])]); if (step === "ai-setup") { setStep("github"); } else if (step === "github") { setStep("first-task"); } }, [step]); // Navigate to previous step const handleBack = useCallback(() => { // Remove current step from completedSteps when going back (undoing progress) const currentStepKey = step; setCompletedSteps(prev => prev.filter(s => s !== currentStepKey)); if (step === "github") { setStep("ai-setup"); } else if (step === "first-task") { setStep("github"); } }, [step]); // OAuth login handler const handleLogin = useCallback( async (providerId: string) => { // Clear any previous terminal outcome before starting a new login attempt setLoginOutcomes((prev) => { const outcome = prev[providerId]; if (outcome && outcome !== "pending") { const { [providerId]: _, ...rest } = prev; return rest; } return prev; }); // Set outcome to pending setLoginOutcomes((prev) => ({ ...prev, [providerId]: "pending" })); setAuthActionInProgress(providerId); pollCountRef.current = 0; try { const { url } = await loginProvider(providerId); window.open(url, "_blank"); // Poll for auth completion pollIntervalRef.current = setInterval(async () => { pollCountRef.current++; // Check for timeout if (pollCountRef.current >= MAX_POLL_CYCLES) { if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); pollIntervalRef.current = null; } setAuthActionInProgress(null); setLoginOutcomes((prev) => ({ ...prev, [providerId]: "timeout" })); addToast("Login timed out. Please try again.", "warning"); return; } 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); setLoginOutcomes((prev) => ({ ...prev, [providerId]: "success" })); addToast("Login successful", "success"); } } catch { // Continue polling } }, 2000); } catch (err: unknown) { // Check for concurrent login (409) conflict const isConcurrentLogin = (err instanceof Error && err.message.includes("already in progress")) || (err && typeof err === "object" && "status" in err && (err as { status: number }).status === 409); if (isConcurrentLogin) { addToast("Login already in progress. Please wait or cancel the current attempt.", "warning"); setLoginOutcomes((prev) => ({ ...prev, [providerId]: "failed" })); } else { addToast(err instanceof Error ? err.message : "Login failed", "error"); setLoginOutcomes((prev) => ({ ...prev, [providerId]: "failed" })); } setAuthActionInProgress(null); } }, [addToast], ); // Cancellation handler for in-progress logins const handleCancelLogin = useCallback((providerId: string) => { if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); pollIntervalRef.current = null; } setAuthActionInProgress(null); pollCountRef.current = 0; setLoginOutcomes((prev) => ({ ...prev, [providerId]: "cancelled" })); }, []); // 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 Optional )} {step === "github" && ( <> Connect GitHub Optional )} {step === "first-task" && ( <> Create Your First Task )} {step === "complete" && ( <> All Set! )}

{step !== "complete" && ( )}
{/* Step indicator - 3 progress steps + complete */}
{steps.map((s, index) => { // A step is done if it's in completedSteps AND is before current position const isDone = completedSteps.includes(s.key) && currentStepIndex > index; // Clickable if it's a completed step (can review) or we're on a future step const isClickable = isDone; return (
{index > 0 && (
)} {isClickable ? ( ) : (
{isDone ? ( ) : ( index + 1 )} {s.label}
)}
); })}
{/* Content */}
{step === "ai-setup" && (

Fusion uses AI models to plan, write, and review code for you. Connect an AI provider below to get started — you can use a hosted service or enter an API key.

{/* Show helper text when providers exist but none are authenticated */} {authProviders.length > 0 && !authProviders.some((p) => p.authenticated) && (

Skip this step if you'd like — you can always add providers later from Settings.

)} {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 ? ( ) : ( )}
{/* Show timeout message */} {loginOutcomes[provider.id] === "timeout" && authActionInProgress !== provider.id && (

Login timed out. Please try again.

)} {/* Show failure message */} {loginOutcomes[provider.id] === "failed" && authActionInProgress !== provider.id && (

Login failed. Please try again.

)}
))} {/* 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)

Pick a default model for AI tasks, or leave this blank to choose later. Models vary in speed, capability, and cost.

{availableModels.length === 0 ? (
No models available yet. Connect a provider above to see model options.
) : (
)} {selectedModel && (
Selected:{" "} {availableModels.find((m) => m.id === selectedModel) ?.name ?? selectedModel}
)}
)} {step === "github" && (

Linking GitHub lets you import issues as tasks and keep pull requests in sync with your work. This is entirely optional — Fusion works without it.

{!hasGithubProvider ? (

GitHub integration isn't set up yet. You can enable it later in Settings → Authentication.

) : ( <>
GitHub {isGithubAuthenticated ? "✓ Connected" : "✗ Not connected"}
{authActionInProgress === "github" ? ( <> ) : isGithubAuthenticated ? ( ) : ( )}
{/* Show timeout message */} {loginOutcomes["github"] === "timeout" && authActionInProgress !== "github" && (

Login timed out. Please try again.

)} {/* Show failure message */} {loginOutcomes["github"] === "failed" && authActionInProgress !== "github" && (

Login failed. Please try again.

)}
{!isGithubAuthenticated && (

No worries if you're not ready — connect GitHub anytime from Settings → Authentication.

)} )}
)} {step === "first-task" && (

Your workspace is ready. Here's how to get started:

You can create tasks anytime from the board, or use{" "} fn task create in the terminal.

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

Setup complete! Head to the board to create your first task, or explore the dashboard to see what's available.

)}
{/* 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]); }