import { useState, useEffect, useCallback, useRef } from "react"; import { X, Loader2, CheckCircle, Key, Zap, GitPullRequest, Rocket, Plus, ChevronRight } from "lucide-react"; import type { AuthProvider, ModelInfo } from "../api"; import { fetchAuthStatus, fetchGlobalSettings, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, updateGlobalSettings, createTask, } from "../api"; import type { ToastType } from "../hooks/useToast"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { ProviderIcon } from "./ProviderIcon"; /** Provider-specific API key setup metadata for onboarding form rendering */ interface ApiKeyInfo { /** Label shown above the input field, e.g. "OpenAI API Key" */ fieldLabel: string; /** Brief setup instructions: where to find/create the key */ setupInstructions: string; /** URL to the provider's API key dashboard (optional) */ dashboardUrl?: string; /** Hint text shown inside the input via placeholder */ inputPlaceholder?: string; /** Brief text explaining where Fusion uses this key */ usageDescription: string; } interface ProviderInfo { description: string; apiKeyInfo?: ApiKeyInfo; } /** Provider metadata with plain-language descriptions for the onboarding UI */ const PROVIDER_INFO: Record = { anthropic: { description: "Claude models — strong at reasoning, analysis, and code" }, openai: { description: "GPT models — versatile for a wide range of tasks", apiKeyInfo: { fieldLabel: "OpenAI API Key", setupInstructions: "Create an API key from your OpenAI dashboard under API keys.", dashboardUrl: "https://platform.openai.com/api-keys", inputPlaceholder: "sk-...", usageDescription: "Used for GPT models in task execution and planning", }, }, "openai-codex": { description: "Codex models by OpenAI — optimized for coding tasks" }, google: { description: "Gemini models — multimodal with strong reasoning" }, gemini: { description: "Gemini models — multimodal with strong reasoning" }, ollama: { description: "Run open-source models locally on your machine", apiKeyInfo: { fieldLabel: "Ollama Endpoint", setupInstructions: "Enter your Ollama endpoint URL (for example http://localhost:11434).", inputPlaceholder: "http://localhost:11434", usageDescription: "Connects to your local Ollama instance", }, }, minimax: { description: "MiniMax models — cost-effective for high-volume usage", apiKeyInfo: { fieldLabel: "MiniMax API Key", setupInstructions: "Generate an API key from the MiniMax platform developer console.", dashboardUrl: "https://platform.minimaxi.com/", inputPlaceholder: "Enter your MiniMax API key", usageDescription: "Used for MiniMax models in task execution", }, }, zai: { description: "GLM models by Zhipu AI — strong multilingual support", apiKeyInfo: { fieldLabel: "Zhipu AI API Key", setupInstructions: "Create an API key in the Zhipu AI open platform account settings.", dashboardUrl: "https://open.bigmodel.cn/", inputPlaceholder: "Enter your Zhipu AI API key", usageDescription: "Used for GLM models in task execution", }, }, kimi: { description: "Kimi by Moonshot AI — long-context capabilities" }, moonshot: { description: "Kimi by Moonshot AI — long-context capabilities" }, "kimi-coding": { description: "Kimi by Moonshot AI — long-context capabilities", apiKeyInfo: { fieldLabel: "Kimi API Key", setupInstructions: "Create your API key in the Moonshot platform account settings.", dashboardUrl: "https://platform.moonshot.cn/", inputPlaceholder: "Enter your Kimi API key", usageDescription: "Used for Kimi/Moonshot AI models in task execution and planning", }, }, openrouter: { description: "OpenRouter — route requests across multiple AI providers", apiKeyInfo: { fieldLabel: "OpenRouter API Key", setupInstructions: "Create an API key from your OpenRouter account key management page.", dashboardUrl: "https://openrouter.ai/keys", inputPlaceholder: "sk-or-v1-...", usageDescription: "Routes to multiple AI model providers through a single key", }, }, }; const PROVIDER_KEY_HINTS: Record = { anthropic: { pattern: /^sk-ant-/, hint: "Starts with sk-ant-", example: "sk-ant-api03-..." }, openai: { pattern: /^sk-/, hint: "Starts with sk-", example: "sk-..." }, "openai-codex": { pattern: /^sk-/, hint: "Starts with sk-", example: "sk-..." }, openrouter: { pattern: /^sk-or-/, hint: "Starts with sk-or-", example: "sk-or-v1-..." }, google: { pattern: /^AIza/, hint: "Starts with AIza", example: "AIza..." }, gemini: { pattern: /^AIza/, hint: "Starts with AIza", example: "AIza..." }, minimax: { pattern: /^.{8,}$/, hint: "At least 8 characters", example: "..." }, ollama: { pattern: /^.+$/, hint: "Any non-empty value", example: "ollama" }, zai: { pattern: /^.{8,}$/, hint: "At least 8 characters", example: "..." }, kimi: { pattern: /^.{8,}$/, hint: "At least 8 characters", example: "..." }, "kimi-coding": { pattern: /^.{8,}$/, hint: "At least 8 characters", example: "..." }, moonshot: { pattern: /^.{8,}$/, hint: "At least 8 characters", example: "..." }, }; const PROVIDER_KEY_HINTS_FALLBACK = { pattern: /^.{8,}$/, hint: "At least 8 characters", example: "...", }; const PROVIDER_DISPLAY_NAMES: Record = { anthropic: "Anthropic", openai: "OpenAI", "openai-codex": "OpenAI Codex", openrouter: "OpenRouter", google: "Google", gemini: "Gemini", minimax: "MiniMax", ollama: "Ollama", zai: "Zhipu AI", kimi: "Kimi", "kimi-coding": "Kimi Coding", moonshot: "Moonshot", }; function getProviderDisplayName(providerId: string): string { if (PROVIDER_DISPLAY_NAMES[providerId]) { return PROVIDER_DISPLAY_NAMES[providerId]; } const normalized = providerId.trim(); if (!normalized) { return "This provider"; } return normalized .split(/[-_\s]+/) .filter(Boolean) .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) .join(" "); } function validateApiKeyFormat(providerId: string, key: string): string | null { const trimmedKey = key.trim(); if (!trimmedKey) { return "API key is required"; } const providerHint = PROVIDER_KEY_HINTS[providerId] ?? PROVIDER_KEY_HINTS_FALLBACK; if (providerHint.pattern.test(trimmedKey)) { return null; } const providerName = getProviderDisplayName(providerId); return `${providerName} keys should follow this format: ${providerHint.hint} (e.g. ${providerHint.example})`; } const API_KEY_INFO_FALLBACK: ApiKeyInfo = { fieldLabel: "API Key", setupInstructions: "Enter your API key for this provider.", inputPlaceholder: "Enter API key", usageDescription: "Used by Fusion to authenticate requests to this provider", }; /** Fallback description for providers not in the map */ const PROVIDER_INFO_FALLBACK: ProviderInfo = { description: "AI provider — connect to start using AI models", apiKeyInfo: API_KEY_INFO_FALLBACK, }; function getProviderInfo(providerId: string): ProviderInfo { return PROVIDER_INFO[providerId] ?? PROVIDER_INFO_FALLBACK; } function getApiKeyInfo(provider: AuthProvider): ApiKeyInfo { const info = getProviderInfo(provider.id); return info.apiKeyInfo ?? API_KEY_INFO_FALLBACK; } /** Props for OnboardingDisclosure component */ interface OnboardingDisclosureProps { summary: string; children: React.ReactNode; className?: string; } /** * Progressive disclosure component that reveals additional content on click. * Used to hide technical details behind expandable "Learn more" sections. */ function OnboardingDisclosure({ summary, children, className = "" }: OnboardingDisclosureProps) { const [isOpen, setIsOpen] = useState(false); return (
{isOpen && (
{children}
)}
); } interface ReadinessItem { label: string; status: "connected" | "missing" | "skipped"; detail?: string; } interface ReadinessSummaryProps { items: ReadinessItem[]; } function ReadinessSummary({ items }: ReadinessSummaryProps) { const hasAttentionItems = items.some((item) => item.status !== "connected"); if (!hasAttentionItems) { return (

✓ All integrations connected

); } return (

Setup Summary

{items.map((item) => { const statusIcon = item.status === "connected" ? "✓" : item.status === "missing" ? "⚠" : "○"; return (
{item.label} {item.detail && {item.detail}}
); })}
); } interface ApiKeyEntryFormProps { provider: AuthProvider; apiKeyInfo: ApiKeyInfo; inputValue: string; isSaving: boolean; error?: string; success?: string | null; isConnected: boolean; onInputChange: (providerId: string, key: string) => void; onSave: (providerId: string, key: string) => void | Promise; onClear: (providerId: string) => void | Promise; } function ApiKeyEntryForm({ provider, apiKeyInfo, inputValue, isSaving, error, success, isConnected, onInputChange, onSave, onClear, }: ApiKeyEntryFormProps) { const inputId = `onboarding-apikey-input-${provider.id}`; const saveDisabled = isSaving || !inputValue.trim(); const providerKeyHint = PROVIDER_KEY_HINTS[provider.id]; const inputClassName = `input onboarding-apikey-input${ error ? " onboarding-apikey-input--error" : "" }${success ? " onboarding-apikey-input--success" : ""}`; if (isConnected) { return (
{apiKeyInfo.fieldLabel} ✓ API key saved

{apiKeyInfo.usageDescription}

{error && {error}}
); } return (
onInputChange(provider.id, e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { onSave(provider.id, inputValue); } }} data-testid={inputId} />
{providerKeyHint && ( Format: {providerKeyHint.hint} )} {error && {error}} {success && !error && ( {success} )}

{apiKeyInfo.setupInstructions}

{apiKeyInfo.dashboardUrl && ( Get your API key → )}

{apiKeyInfo.usageDescription}

); } import { getOnboardingState, saveOnboardingState, clearOnboardingState, markOnboardingCompleted, markStepSkipped, getSkippedSteps, getStepData, type OnboardingStep, } from "./model-onboarding-state"; import { trackOnboardingEvent } from "./onboarding-events"; 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; /** First task created from the onboarding flow, if available */ firstCreatedTask?: Task | null; /** Optional callback when user wants to open the created task detail */ onViewTask?: (task: Task) => void; } /** Outcome states for OAuth login attempts */ export type LoginOutcome = "pending" | "success" | "timeout" | "failed" | "cancelled"; /** Provider connection status for UI display */ export type ProviderConnectionStatus = "connected" | "not-connected" | "skipped" | "retry"; /** GitHub-specific status variants for richer connection feedback */ type GitHubConnectionStatus = "connected" | "failed" | "pending" | "skipped" | "not-connected"; /** 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, firstCreatedTask, onViewTask, }: 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/skipped steps from persisted state const persistedCompletedSteps = persistedState?.completedSteps ?? []; const persistedSkippedSteps = persistedState?.skippedSteps ?? getSkippedSteps(); const [isOpen, setIsOpen] = useState(true); const [step, setStep] = useState(initialStep); const [completedSteps, setCompletedSteps] = useState(persistedCompletedSteps); const [skippedSteps, setSkippedSteps] = useState(persistedSkippedSteps); const [showTaskCreated, setShowTaskCreated] = useState(false); const [firstTaskDescription, setFirstTaskDescription] = useState(""); const [isCreatingFirstTask, setIsCreatingFirstTask] = useState(false); const [taskCreationError, setTaskCreationError] = useState(null); const [inlineCreatedTask, setInlineCreatedTask] = useState(null); 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 [apiKeySuccess, setApiKeySuccess] = useState>({}); const apiKeySuccessTimers = useRef>>({}); const pollIntervalRef = useRef | null>(null); const [loginOutcomes, setLoginOutcomes] = useState>({}); const [isGithubSkipped, setIsGithubSkipped] = useState(() => { const state = getOnboardingState(); return state?.stepData?.github?.skipped === true; }); const pollCountRef = useRef(0); const previousCreatedTaskRef = useRef(firstCreatedTask); const hasTrackedWizardOpenRef = useRef(false); const resumedFromStep = persistedState?.currentStep; const isResumedFlow = !!persistedState && persistedState.currentStep !== "complete"; // Initialize skippedProviders from persisted state const [skippedProviders, setSkippedProviders] = useState>( () => { const state = getOnboardingState(); const data = state?.stepData?.["ai-setup"]; return (data?.skippedProviders as Record) ?? {}; } ); // 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, skippedSteps }); } }, [step, completedSteps, skippedSteps]); useEffect(() => { if (hasTrackedWizardOpenRef.current) { return; } hasTrackedWizardOpenRef.current = true; trackOnboardingEvent("onboarding:wizard-opened", { source: isResumedFlow ? "resume" : "initial", resumedFromStep, }); }, [isResumedFlow, resumedFromStep]); useEffect(() => { const hadCreatedTask = previousCreatedTaskRef.current != null; const hasCreatedTask = firstCreatedTask != null; if (!hadCreatedTask && hasCreatedTask) { setShowTaskCreated(true); } if (!hasCreatedTask) { setShowTaskCreated(false); } previousCreatedTaskRef.current = firstCreatedTask; }, [firstCreatedTask]); // Auto-mark unconnected providers as skipped when leaving ai-setup step // Only skip if NO providers are connected (if at least one is connected, others remain "Not connected") const prevStepRef = useRef(initialStep); useEffect(() => { if (prevStepRef.current === "ai-setup" && step !== "ai-setup") { // Check if any AI provider is connected const hasConnectedProvider = authProviders.some( (p) => p.id !== "github" && p.authenticated ); // Only mark as skipped if no providers are connected if (!hasConnectedProvider) { const newlySkipped: Record = {}; for (const p of authProviders) { if (p.id !== "github" && !p.authenticated && !skippedProviders[p.id]) { newlySkipped[p.id] = true; } } if (Object.keys(newlySkipped).length > 0) { const updated = { ...skippedProviders, ...newlySkipped }; setSkippedProviders(updated); saveOnboardingState(step, { stepData: { "ai-setup": { skippedProviders: updated } }, }); } } } prevStepRef.current = step; }, [step, authProviders, skippedProviders]); // Load auth providers const loadAuthStatus = useCallback(async () => { try { const { providers } = await fetchAuthStatus(); setAuthProviders(providers); // Remove from skippedProviders when a provider becomes authenticated setSkippedProviders((prev) => { const updated = { ...prev }; for (const p of providers) { if (p.authenticated && updated[p.id]) { delete updated[p.id]; } } return Object.keys(updated).length === Object.keys(prev).length ? prev : updated; }); } catch { // Silently fail } }, []); // Reload auth status when returning to AI Setup step from another step (not on initial mount) const aiSetupReturnRef = useRef(false); useEffect(() => { if (aiSetupReturnRef.current) { loadAuthStatus(); } aiSetupReturnRef.current = step !== "ai-setup"; }, [step, loadAuthStatus]); // Check if GitHub provider is configured and currently authenticated const githubProvider = authProviders.find((p) => p.id === "github"); const hasGithubProvider = !!githubProvider; const isGithubAuthenticated = githubProvider?.authenticated ?? false; // Get provider connection status for UI display const getProviderStatus = useCallback((provider: AuthProvider): ProviderConnectionStatus => { if (provider.authenticated) { return "connected"; } // Check for retry-able failure states (from login outcomes) const loginOutcome = (loginOutcomes as Record | undefined)?.[provider.id]; if (loginOutcome === "timeout" || loginOutcome === "failed") { return "retry"; } if (skippedProviders[provider.id]) { return "skipped"; } return "not-connected"; }, [loginOutcomes, skippedProviders]); // Status badge component for provider connection status function ProviderStatusBadge({ status }: { status: ProviderConnectionStatus }) { const config: Record = { connected: { text: "✓ Connected", className: "auth-status-badge connected" }, "not-connected": { text: "Not connected", className: "auth-status-badge not-connected" }, skipped: { text: "Skipped", className: "auth-status-badge skipped" }, retry: { text: "Retry", className: "auth-status-badge retry" }, }; const { text, className: badgeClassName } = config[status]; return ( {text} ); } const getGitHubStatus = useCallback((): GitHubConnectionStatus => { if (isGithubAuthenticated) { return "connected"; } const githubOutcome = loginOutcomes["github"]; if (githubOutcome === "pending") { return "pending"; } if (githubOutcome === "failed" || githubOutcome === "timeout") { return "failed"; } if (isGithubSkipped) { return "skipped"; } return "not-connected"; }, [isGithubAuthenticated, loginOutcomes, isGithubSkipped]); function GitHubStatusBadge({ status }: { status: GitHubConnectionStatus }) { const config: Record = { connected: { text: "✓ Connected", className: "auth-status-badge connected" }, pending: { text: "⏳ Connecting…", className: "auth-status-badge pending" }, failed: { text: "✗ Connection failed", className: "auth-status-badge retry" }, skipped: { text: "Skipped", className: "auth-status-badge skipped" }, "not-connected": { text: "Not connected", className: "auth-status-badge not-connected" }, }; const { text, className: badgeClassName } = config[status]; return ( {text} ); } // 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]); // Cleanup polling on unmount useEffect(() => { return () => { if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); } Object.values(apiKeySuccessTimers.current).forEach(clearTimeout); apiKeySuccessTimers.current = {}; }; }, []); const setGitHubSkippedState = useCallback((skipped: boolean) => { setIsGithubSkipped(skipped); saveOnboardingState(step, { completedSteps, stepData: { github: { skipped, }, }, }); }, [step, completedSteps]); // Navigate to next step const handleNext = useCallback(() => { // Mark current step as completed before moving forward setCompletedSteps((prev) => [...new Set([...prev, step])]); // Completing a step clears any prior skipped status setSkippedSteps((prev) => prev.filter((s) => s !== step)); trackOnboardingEvent("onboarding:step-completed", { step }); if (step === "github" && !isGithubAuthenticated) { setGitHubSkippedState(false); } if (step === "ai-setup") { setStep("github"); } else if (step === "github") { setStep("first-task"); } }, [step, isGithubAuthenticated, setGitHubSkippedState]); // Navigate forward without marking completion const handleSkip = useCallback(() => { setSkippedSteps((prev) => [...new Set([...prev, step])]); markStepSkipped(step); trackOnboardingEvent("onboarding:step-skipped", { step }); if (step === "github" && !isGithubAuthenticated) { setGitHubSkippedState(true); } if (step === "ai-setup") { setStep("github"); } else if (step === "github") { setStep("first-task"); } }, [step, isGithubAuthenticated, setGitHubSkippedState]); // Navigate to previous step const handleBack = useCallback(() => { // Remove current step from completed/skipped when going back (undoing progress) const currentStepKey = step; setCompletedSteps((prev) => prev.filter((s) => s !== currentStepKey)); setSkippedSteps((prev) => prev.filter((s) => s !== currentStepKey)); if (currentStepKey === "github" && !isGithubAuthenticated) { setGitHubSkippedState(false); } if (step === "github") { setStep("ai-setup"); } else if (step === "first-task") { setStep("github"); } }, [step, isGithubAuthenticated, setGitHubSkippedState]); const handleSkipGitHubStep = useCallback(() => { handleSkip(); }, [handleSkip]); // 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" })); if (providerId === "github") { setGitHubSkippedState(false); } 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, setGitHubSkippedState], ); // 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 input update handler const handleApiKeyInputChange = useCallback((providerId: string, value: string) => { setApiKeyInputs((prev) => ({ ...prev, [providerId]: value, })); const successTimer = apiKeySuccessTimers.current[providerId]; if (successTimer) { clearTimeout(successTimer); delete apiKeySuccessTimers.current[providerId]; } setApiKeyErrors((prev) => { if (!prev[providerId]) { return prev; } const next = { ...prev }; delete next[providerId]; return next; }); setApiKeySuccess((prev) => { if (!prev[providerId]) { return prev; } const next = { ...prev }; delete next[providerId]; return next; }); }, []); // API key save handler const handleSaveApiKey = useCallback( async (providerId: string, keyValue?: string) => { const key = (keyValue ?? apiKeyInputs[providerId] ?? "").trim(); const validationError = validateApiKeyFormat(providerId, key); if (validationError) { setApiKeyErrors((prev) => ({ ...prev, [providerId]: validationError, })); return; } const existingTimer = apiKeySuccessTimers.current[providerId]; if (existingTimer) { clearTimeout(existingTimer); delete apiKeySuccessTimers.current[providerId]; } setAuthActionInProgress(providerId); setApiKeyErrors((prev) => { const next = { ...prev }; delete next[providerId]; return next; }); setApiKeySuccess((prev) => { if (!prev[providerId]) { return 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; }); setApiKeyErrors((prev) => { if (!prev[providerId]) { return prev; } const next = { ...prev }; delete next[providerId]; return next; }); setApiKeySuccess((prev) => ({ ...prev, [providerId]: "✓ Key saved", })); apiKeySuccessTimers.current[providerId] = setTimeout(() => { setApiKeySuccess((prev) => { if (!prev[providerId]) { return prev; } const next = { ...prev }; delete next[providerId]; return next; }); delete apiKeySuccessTimers.current[providerId]; }, 3000); addToast("API key saved", "success"); } catch (err: unknown) { const errorMessage = err instanceof TypeError && err.message.includes("Failed to fetch") ? "Could not reach the server. Check your connection and try again." : err instanceof Error ? err.message : "Failed to save API key"; setApiKeyErrors((prev) => ({ ...prev, [providerId]: errorMessage, })); setApiKeySuccess((prev) => { if (!prev[providerId]) { return prev; } const next = { ...prev }; delete next[providerId]; return next; }); addToast(errorMessage, "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); }, []); const completeOnboarding = useCallback(async () => { 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) { // Fallback: use parsed values even if not in the model list 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 } }, [selectedModel, availableModels, updateGlobalSettings, markOnboardingCompleted]); // Complete onboarding const handleComplete = useCallback(async () => { setSaving(true); try { await completeOnboarding(); trackOnboardingEvent("onboarding:completed", { completedSteps, skippedSteps }); setStep("complete"); } finally { setSaving(false); } }, [completeOnboarding, completedSteps, skippedSteps]); const handleCreateFirstTask = useCallback(async () => { const trimmedDescription = firstTaskDescription.trim(); if (!trimmedDescription) { setTaskCreationError("Please enter a task description."); return; } setTaskCreationError(null); setIsCreatingFirstTask(true); let success = false; try { const createdTask = await createTask({ description: trimmedDescription }); setInlineCreatedTask(createdTask); setShowTaskCreated(true); trackOnboardingEvent("onboarding:first-task-created", { taskId: createdTask?.id }); addToast("Task created", "success"); success = true; } catch (err: unknown) { const message = err instanceof Error ? err.message : "Something went wrong creating your task. Please try again."; setTaskCreationError(message); addToast(message, "error"); } finally { setIsCreatingFirstTask(false); } if (success) { void completeOnboarding(); } }, [firstTaskDescription, addToast, completeOnboarding]); // Handle first task CTA - mark complete, close modal, then open new task const handleOpenNewTask = useCallback(async () => { // First complete the onboarding setSaving(true); try { await completeOnboarding(); } finally { setSaving(false); } // Keep onboarding open so task creation can hand back to a success state trackOnboardingEvent("onboarding:open-new-task", {}); onOpenNewTask?.(); }, [completeOnboarding, onOpenNewTask]); // Handle GitHub import CTA - mark complete, close modal, then open GitHub import const handleOpenGitHubImport = useCallback(async () => { // First complete the onboarding setSaving(true); try { await completeOnboarding(); } finally { setSaving(false); } // Close modal and trigger callback setIsOpen(false); onComplete(); trackOnboardingEvent("onboarding:open-github-import", {}); onOpenGitHubImport?.(); }, [completeOnboarding, onComplete, onOpenGitHubImport]); // Dismiss without completing (still marks onboarding complete) const handleDismiss = useCallback(async () => { trackOnboardingEvent("onboarding:dismissed", { currentStep: step, completedSteps, skippedSteps, }); setSaving(true); try { await updateGlobalSettings({ modelOnboardingComplete: true }); } catch { // Best-effort: still close even if save fails } setIsOpen(false); onComplete(); }, [step, completedSteps, skippedSteps, onComplete]); // Close from the completion step const handleFinish = useCallback(() => { trackOnboardingEvent("onboarding:finished", {}); setIsOpen(false); onComplete(); }, [onComplete]); const handleViewCreatedTask = useCallback(() => { const createdTask = firstCreatedTask ?? inlineCreatedTask; if (!createdTask) { return; } void completeOnboarding(); onViewTask?.(createdTask); onComplete(); }, [firstCreatedTask, inlineCreatedTask, completeOnboarding, onViewTask, onComplete]); const handleGoToDashboard = useCallback(() => { void completeOnboarding(); onComplete(); }, [completeOnboarding, 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"); const githubStatus = getGitHubStatus(); const aiProviders = authProviders.filter((provider) => provider.id !== "github"); const connectedAiProviders = aiProviders.filter((provider) => provider.authenticated); const hasAiProvider = connectedAiProviders.length > 0; // True when on GitHub step but skipped AI setup (no AI provider connected) const aiSetupSkipped = step === "github" && !hasAiProvider; const selectedModelDisplayName = (() => { if (!selectedModel) { return ""; } const slashIdx = selectedModel.indexOf("/"); const providerId = slashIdx === -1 ? undefined : selectedModel.slice(0, slashIdx); const modelId = slashIdx === -1 ? selectedModel : selectedModel.slice(slashIdx + 1); const matchingModel = availableModels.find( (model) => model.id === modelId && (!providerId || model.provider === providerId), ); if (matchingModel?.name) { return matchingModel.name; } if (providerId) { return `${getProviderDisplayName(providerId)} ${modelId}`; } return selectedModel; })(); const readinessItems: ReadinessItem[] = []; if (hasAiProvider) { const firstConnectedProviderName = getProviderDisplayName(connectedAiProviders[0]?.id ?? ""); readinessItems.push({ label: "AI Provider", status: "connected", detail: `${firstConnectedProviderName} connected — AI agents can work on tasks`, }); } else if ( aiProviders.length > 0 && aiProviders.some((provider) => skippedProviders[provider.id]) ) { readinessItems.push({ label: "AI Provider", status: "skipped", detail: "AI agents won't be available until you connect a provider", }); } else { readinessItems.push({ label: "AI Provider", status: "missing", detail: "Connect a provider in Settings → AI Setup", }); } if (isGithubAuthenticated) { readinessItems.push({ label: "GitHub", status: "connected", detail: "Issues and PRs can be imported", }); } else if (!hasGithubProvider || isGithubSkipped) { readinessItems.push({ label: "GitHub", status: "skipped", detail: "You can connect anytime from Settings", }); } else { readinessItems.push({ label: "GitHub", status: "missing", detail: "Connect to import issues as tasks", }); } if (selectedModelDisplayName) { readinessItems.push({ label: "Default Model", status: "connected", detail: selectedModelDisplayName, }); } const createdTaskForDisplay = firstCreatedTask ?? inlineCreatedTask; const firstCreatedTaskPreview = createdTaskForDisplay?.description?.split("\n")[0]?.trim() || createdTaskForDisplay?.title || ""; 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; const isSkipped = skippedSteps.includes(s.key) && !completedSteps.includes(s.key) && currentStepIndex > index; // Clickable if it's a completed/skipped step (can review) const isClickable = isDone || isSkipped; return (
{index > 0 && (
)} {isClickable ? ( ) : (
{isDone ? ( ) : isSkipped ? ( ) : ( 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.

{/* Provider connection status summary */} {!authLoading && authProviders.length > 0 && ( (() => { const connectedCount = authProviders.filter(p => p.id !== "github" && p.authenticated).length; const totalAiProviders = authProviders.filter(p => p.id !== "github").length; const skippedCount = Object.keys(skippedProviders).filter(id => !authProviders.find(p => p.id === id)?.authenticated).length; const connectedProviders = authProviders.filter(p => p.id !== "github" && p.authenticated); if (totalAiProviders === 0) return null; let summaryClass = "onboarding-provider-summary"; let summaryText = ""; if (connectedCount > 0) { summaryClass += " onboarding-provider-summary--connected"; summaryText = `✓ ${connectedCount} of ${totalAiProviders} provider${totalAiProviders !== 1 ? "s" : ""} connected`; } else if (skippedCount > 0) { summaryClass += " onboarding-provider-summary--skipped"; summaryText = `${skippedCount} provider${skippedCount !== 1 ? "s" : ""} skipped`; } else { summaryClass += " onboarding-provider-summary--none"; summaryText = "No providers connected yet"; } return (
{summaryText}
); })() )} {/* Provider explanation disclosure */}

AI providers like OpenAI and Anthropic power the AI capabilities in Fusion. Connecting a provider lets Fusion's agents use AI models to help with your tasks. You only need one provider to get started.

{/* 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.length > 0 && ( <> {aiOauthProviders.map((provider) => (
{provider.name} {getProviderInfo(provider.id).description}
{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.

)}
))} {/* OAuth login disclosure */}

Clicking Login opens the provider's website in a new tab where you sign in. Once you authorize Fusion, this page will automatically detect the connection. Your credentials are never stored in Fusion.

)} {/* API Key Providers */} {aiApiKeyProviders.length > 0 && ( <> {aiApiKeyProviders.map((provider) => { const providerInfo = getProviderInfo(provider.id); const apiKeyInfo = getApiKeyInfo(provider); return (
{provider.name} {providerInfo.description}
); })} {/* API key disclosure */}

An API key is a secret token that authenticates Fusion with the provider. You can find your key in the provider's dashboard under API settings. Keys are stored securely on your machine.

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

Models vary in speed, capability, and cost. A good default is usually the latest model from your connected provider. You can always change this later in Settings.

{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" && (

Connecting GitHub unlocks issue imports and pull request tracking. You can skip this — task creation works without it.

  • Without GitHub (available now):
  • Create tasks manually
  • Describe work for AI agents
  • Track progress on the board
  • With GitHub (after connecting):
  • Import issues as tasks
  • Sync pull request status
  • Link code changes to tasks
{/* Skip-state banner: shown when AI setup was skipped */} {aiSetupSkipped && (
No AI provider connected

AI features like task planning and code generation won't be available until you connect one. You can set this up later in Settings.

)}

Without GitHub, you can still create and manage tasks manually. GitHub integration adds the ability to import issues as tasks, track pull request status alongside your work, and automatically link commits to tasks. Connect anytime from Settings → Authentication.

{!hasGithubProvider ? (

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

) : ( <>
GitHub
{isGithubAuthenticated && ( authActionInProgress === "github" ? ( ) : ( ) )}
{(githubStatus === "not-connected" || githubStatus === "pending") && (
{authActionInProgress === "github" ? (
) : ( )}
)} {githubStatus === "connected" && (
GitHub is connected. You can import issues and track pull requests.
)} {githubStatus === "failed" && (

Connection failed or timed out.

)} {githubStatus === "pending" && (
Waiting for GitHub authorization…
)} {githubStatus === "skipped" && (

GitHub was skipped. You can connect anytime from Settings → Authentication.

)} {githubStatus === "not-connected" && (

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:

{showTaskCreated && createdTaskForDisplay ? (

Your first task is ready!

{createdTaskForDisplay.id}
{firstCreatedTaskPreview && (

{firstCreatedTaskPreview}

)}

Your task has been created and will appear on the board.

) : ( <>