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, } from "../api"; import type { ToastType } from "../hooks/useToast"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { ProviderIcon } from "./ProviderIcon"; /** 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" }, "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" }, minimax: { description: "MiniMax models — cost-effective for high-volume usage" }, zai: { description: "GLM models by Zhipu AI — strong multilingual support" }, kimi: { description: "Kimi by Moonshot AI — long-context capabilities" }, moonshot: { description: "Kimi by Moonshot AI — long-context capabilities" }, }; /** Fallback description for providers not in the map */ const PROVIDER_INFO_FALLBACK = { description: "AI provider — connect to start using AI models" }; /** 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}
)}
); } 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"; /** Provider connection status for UI display */ export type ProviderConnectionStatus = "connected" | "not-connected" | "skipped" | "retry"; /** 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); // 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 }); } }, [step, completedSteps]); // 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]); // 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} ); } // 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.

{/* 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} {PROVIDER_INFO[provider.id]?.description ?? PROVIDER_INFO_FALLBACK.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) => (
{provider.name} {PROVIDER_INFO[provider.id]?.description ?? PROVIDER_INFO_FALLBACK.description}
{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]} )}
))} {/* 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" && (

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.

Connecting GitHub lets you import issues as tasks, track pull requests, and link code changes to your work. This is optional — you can create tasks manually 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:

A task describes something you want done. Fusion's AI agents will read your description and work on implementing it. You can track progress on the board and review the results.

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