import { useState, useEffect, useCallback, useRef } from "react"; import { X, Loader2, CheckCircle, Key, Zap } from "lucide-react"; import type { AuthProvider, ModelInfo } from "../api"; import { fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, updateGlobalSettings, } from "../api"; import type { ToastType } from "../hooks/useToast"; import { CustomModelDropdown } from "./CustomModelDropdown"; export interface ModelOnboardingModalProps { /** Called when onboarding is complete or dismissed */ onComplete: () => void; /** Toast helper */ addToast: (message: string, type?: ToastType) => void; } type OnboardingStep = "providers" | "model" | "complete"; /** * First-run onboarding modal that guides users through: * 1. Provider credential setup (OAuth login or API key entry) * 2. Default model selection * * Dismissing the modal marks onboarding as complete to prevent repeated popups. */ export function ModelOnboardingModal({ onComplete, addToast, }: ModelOnboardingModalProps) { const [isOpen, setIsOpen] = useState(true); const [step, setStep] = useState("providers"); 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); // 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 } }, []); // Initial data load useEffect(() => { Promise.all([loadAuthStatus(), loadModels()]).finally(() => setAuthLoading(false), ); }, [loadAuthStatus, loadModels]); // Check if we can skip the providers step (already authenticated) const hasAuthenticatedProvider = authProviders.some((p) => p.authenticated); // Auto-advance to model step when provider is authenticated useEffect(() => { if (!authLoading && hasAuthenticatedProvider && step === "providers") { // Small delay to let the user see the success state const timer = setTimeout(() => setStep("model"), 600); return () => clearTimeout(timer); } }, [authLoading, hasAuthenticatedProvider, step]); // Cleanup polling on unmount useEffect(() => { return () => { if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); } }; }, []); // 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"); } catch (err: unknown) { addToast( err instanceof Error ? err.message : "Failed to save settings", "error", ); } finally { setSaving(false); } }, [selectedModel, availableModels, addToast]); // 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"); return (
{/* Header */}

{step === "providers" && ( <> Set Up AI Provider )} {step === "model" && ( <> Choose Default Model )} {step === "complete" && ( <> All Set! )}

{step !== "complete" && ( )}
{/* Step indicator */}
1 Connect Provider
2 Select Model
{/* Content */}
{step === "providers" && (

Connect at least one AI provider to start running tasks. {oauthProviders.length > 0 && apiKeyProviders.length > 0 && " OAuth providers open a browser for login; API key providers need a key from the provider's dashboard."}

{authLoading ? (
Loading providers…
) : authProviders.length === 0 ? (
No AI providers are configured. Please check your Fusion configuration.
) : ( <> {/* OAuth Providers */} {oauthProviders.map((provider) => (
{provider.name} {provider.authenticated ? "✓ Authenticated" : "✗ Not authenticated"}
{authActionInProgress === provider.id ? ( ) : provider.authenticated ? ( ) : ( )}
))} {/* API Key Providers */} {apiKeyProviders.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]} )}
))} )}
)} {step === "model" && (

Select the default model Fusion will use for AI tasks. You can change this later in Settings.

{availableModels.length === 0 ? (
No models available. Please check your provider configuration.
) : (
)} {selectedModel && (
Selected:{" "} {availableModels.find((m) => m.id === selectedModel)?.name ?? selectedModel}
)}
)} {step === "complete" && (

You're ready to start using Fusion! You can always change your model and provider settings from the Settings panel.

)}
{/* Footer */}
{step === "providers" && ( <> )} {step === "model" && ( <> )} {step === "complete" && ( )}
); }