Files
fusion/packages/dashboard/app/components/ModelOnboardingModal.tsx
2026-04-16 09:38:12 -07:00

1651 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, { description: string }> = {
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 (
<div className={`onboarding-disclosure ${className}`}>
<button
className="onboarding-disclosure-trigger"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
type="button"
>
<ChevronRight
size={14}
className="onboarding-disclosure-chevron"
aria-hidden="true"
/>
<span>{summary}</span>
</button>
{isOpen && (
<div className="onboarding-disclosure-content">
{children}
</div>
)}
</div>
);
}
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<OnboardingStep>(initialStep);
const [completedSteps, setCompletedSteps] = useState<OnboardingStep[]>(persistedCompletedSteps);
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
const [authLoading, setAuthLoading] = useState(true);
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
const [saving, setSaving] = useState(false);
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [loginOutcomes, setLoginOutcomes] = useState<Record<string, LoginOutcome>>({});
const pollCountRef = useRef<number>(0);
// Initialize skippedProviders from persisted state
const [skippedProviders, setSkippedProviders] = useState<Record<string, boolean>>(
() => {
const state = getOnboardingState();
const data = state?.stepData?.["ai-setup"];
return (data?.skippedProviders as Record<string, boolean>) ?? {};
}
);
// 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<OnboardingStep>(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<string, boolean> = {};
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<string, string> | 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<ProviderConnectionStatus, { text: string; className: string }> = {
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 (
<span
data-testid="provider-status-badge"
className={badgeClassName}
data-status={status}
>
{text}
</span>
);
}
// 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<string, LoginOutcome>;
// Filter out stale "pending" entries from previous sessions
const filteredOutcomes: Record<string, LoginOutcome> = {};
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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 (
<div
className="modal-overlay open"
role="dialog"
aria-modal="true"
aria-labelledby="onboarding-title"
>
<div className="modal model-onboarding-modal">
{/* Header */}
<div className="model-onboarding-header">
<h2 id="onboarding-title" className="model-onboarding-title">
{step === "ai-setup" && (
<>
<Zap size={24} /> Set Up AI <span className="onboarding-optional-badge">Optional</span>
</>
)}
{step === "github" && (
<>
<GitPullRequest size={24} /> Connect GitHub <span className="onboarding-optional-badge">Optional</span>
</>
)}
{step === "first-task" && (
<>
<Rocket size={24} /> Create Your First Task
</>
)}
{step === "complete" && (
<>
<CheckCircle size={24} /> All Set!
</>
)}
</h2>
{step !== "complete" && (
<button
className="modal-close"
onClick={handleDismiss}
aria-label="Skip onboarding"
title="Skip for now"
>
<X size={20} />
</button>
)}
</div>
{/* Step indicator - 3 progress steps + complete */}
<div className="model-onboarding-steps">
{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 (
<div key={s.key} className="onboarding-step-wrapper">
{index > 0 && (
<div
className={`model-onboarding-step-connector ${
index <= currentStepIndex ? "done" : ""
}`}
/>
)}
{isClickable ? (
<button
className={`model-onboarding-step-indicator ${
step === s.key ? "active" : ""
} ${isDone ? "done" : ""}`}
onClick={() => setStep(s.key)}
aria-label={`Go back to ${s.label}`}
title={`Review ${s.label}`}
>
<span className="step-number">
{isDone ? (
<CheckCircle size={14} />
) : (
index + 1
)}
</span>
<span className="step-label">{s.label}</span>
</button>
) : (
<div
className={`model-onboarding-step-indicator ${
step === s.key ? "active" : ""
} ${isDone ? "done" : ""}`}
>
<span className="step-number">
{isDone ? (
<CheckCircle size={14} />
) : (
index + 1
)}
</span>
<span className="step-label">{s.label}</span>
</div>
)}
</div>
);
})}
</div>
{/* Content */}
<div className="model-onboarding-content">
{step === "ai-setup" && (
<div className="model-onboarding-ai-setup">
<p className="model-onboarding-description">
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.
</p>
{/* 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 (
<div className={summaryClass} data-testid="provider-summary">
{summaryText}
</div>
);
})()
)}
{/* Provider explanation disclosure */}
<OnboardingDisclosure summary="What are AI providers?">
<p className="onboarding-helper-text">
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.
</p>
</OnboardingDisclosure>
{/* Show helper text when providers exist but none are authenticated */}
{authProviders.length > 0 && !authProviders.some((p) => p.authenticated) && (
<p className="onboarding-helper-text">
Skip this step if you'd like you can always add providers later from Settings.
</p>
)}
{authLoading ? (
<div className="model-onboarding-loading">
<Loader2 size={24} className="animate-spin" />
<span>Loading providers</span>
</div>
) : authProviders.length === 0 ? (
<div className="model-onboarding-empty">
No AI providers are configured. Please check your Fusion
configuration.
</div>
) : (
<>
{/* OAuth Providers */}
{aiOauthProviders.length > 0 && (
<>
{aiOauthProviders.map((provider) => (
<div
key={provider.id}
className={`onboarding-provider-card${provider.authenticated ? " onboarding-provider-card--connected" : ""}`}
>
<div className="onboarding-provider-card__icon">
<ProviderIcon provider={provider.id} size="md" />
</div>
<div className="onboarding-provider-card__body">
<strong className="onboarding-provider-card__name">{provider.name}</strong>
<span className="onboarding-provider-card__description">
{PROVIDER_INFO[provider.id]?.description ?? PROVIDER_INFO_FALLBACK.description}
</span>
<ProviderStatusBadge status={getProviderStatus(provider)} />
</div>
<div className="onboarding-provider-card__actions">
{authActionInProgress === provider.id ? (
<>
<button className="btn btn-sm" disabled>
{provider.authenticated
? "Logging out…"
: "Waiting for login…"}
</button>
<button
className="btn btn-sm"
onClick={() => handleCancelLogin(provider.id)}
>
Cancel
</button>
</>
) : provider.authenticated ? (
<button
className="btn btn-sm"
onClick={() => handleLogout(provider.id)}
>
Logout
</button>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleLogin(provider.id)}
>
Login
</button>
)}
</div>
{/* Show timeout message */}
{loginOutcomes[provider.id] === "timeout" && authActionInProgress !== provider.id && (
<p className="onboarding-helper-text" style={{ marginTop: 4 }}>
Login timed out. Please try again.
</p>
)}
{/* Show failure message */}
{loginOutcomes[provider.id] === "failed" && authActionInProgress !== provider.id && (
<p className="field-error" style={{ marginTop: 4 }}>
Login failed. Please try again.
</p>
)}
</div>
))}
{/* OAuth login disclosure */}
<OnboardingDisclosure summary="How does login work?">
<p className="onboarding-helper-text">
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.
</p>
</OnboardingDisclosure>
</>
)}
{/* API Key Providers */}
{aiApiKeyProviders.length > 0 && (
<>
{aiApiKeyProviders.map((provider) => (
<div
key={provider.id}
className={`onboarding-provider-card${provider.authenticated ? " onboarding-provider-card--connected" : ""}`}
>
<div className="onboarding-provider-card__icon">
<ProviderIcon provider={provider.id} size="md" />
</div>
<div className="onboarding-provider-card__body">
<strong className="onboarding-provider-card__name">
<Key size={14} style={{ marginRight: 4 }} />
{provider.name}
</strong>
<span className="onboarding-provider-card__description">
{PROVIDER_INFO[provider.id]?.description ?? PROVIDER_INFO_FALLBACK.description}
</span>
<ProviderStatusBadge status={getProviderStatus(provider)} />
</div>
<div className="onboarding-provider-card__actions">
{provider.authenticated ? (
<button
className="btn btn-sm"
onClick={() => handleClearApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
>
{authActionInProgress === provider.id
? "Removing…"
: "Remove Key"}
</button>
) : (
<div className="onboarding-apikey-input-row">
<input
type="password"
className="onboarding-apikey-input"
placeholder={`Enter ${provider.name} API key`}
value={apiKeyInputs[provider.id] ?? ""}
onChange={(e) =>
setApiKeyInputs((prev) => ({
...prev,
[provider.id]: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSaveApiKey(provider.id);
}
}}
data-testid={`onboarding-apikey-input-${provider.id}`}
/>
<button
className="btn btn-primary btn-sm"
onClick={() => handleSaveApiKey(provider.id)}
disabled={
authActionInProgress === provider.id ||
!apiKeyInputs[provider.id]?.trim()
}
data-testid={`onboarding-apikey-save-${provider.id}`}
>
{authActionInProgress === provider.id
? "Saving…"
: "Save"}
</button>
</div>
)}
{apiKeyErrors[provider.id] && (
<small className="field-error">
{apiKeyErrors[provider.id]}
</small>
)}
</div>
</div>
))}
{/* API key disclosure */}
<OnboardingDisclosure summary="What is an API key?">
<p className="onboarding-helper-text">
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.
</p>
</OnboardingDisclosure>
</>
)}
</>
)}
{/* Model Selection */}
<div className="onboarding-model-section">
<h3 className="onboarding-section-title">
Default Model (Optional)
</h3>
<p className="model-onboarding-description">
Pick a default model for AI tasks, or leave this blank to choose
later. Models vary in speed, capability, and cost.
</p>
<OnboardingDisclosure summary="How do I choose a model?">
<p className="onboarding-helper-text">
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.
</p>
</OnboardingDisclosure>
{availableModels.length === 0 ? (
<div className="model-onboarding-empty">
No models available yet. Connect a provider above to see model options.
</div>
) : (
<div className="onboarding-model-selector">
<CustomModelDropdown
models={availableModels}
value={selectedModel}
onChange={handleModelSelect}
placeholder="Select a default model…"
label="Default model"
/>
</div>
)}
{selectedModel && (
<div className="onboarding-model-preview">
<small className="settings-muted">
Selected:{" "}
{availableModels.find((m) => m.id === selectedModel)
?.name ?? selectedModel}
</small>
</div>
)}
</div>
</div>
)}
{step === "github" && (
<div className="model-onboarding-github">
<p className="model-onboarding-description">
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.
</p>
<OnboardingDisclosure summary="What does GitHub integration do?">
<p className="onboarding-helper-text">
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.
</p>
</OnboardingDisclosure>
{!hasGithubProvider ? (
<div className="model-onboarding-github-optional">
<GitPullRequest size={48} className="optional-icon" />
<p>
GitHub integration isn't set up yet. You can enable it later
in Settings → Authentication.
</p>
<button
className="btn btn-sm"
onClick={() => setStep("first-task")}
>
Continue without GitHub →
</button>
</div>
) : (
<>
<div className="onboarding-provider-row">
<div className="onboarding-provider-info">
<strong>
<GitPullRequest size={16} style={{ marginRight: 8 }} />
GitHub
</strong>
<span
data-testid="onboarding-auth-status-github"
className={`auth-status-badge ${isGithubAuthenticated ? "connected" : "not-connected"}`}
>
{isGithubAuthenticated
? "✓ Connected"
: "Not connected"}
</span>
</div>
<div>
{authActionInProgress === "github" ? (
<>
<button className="btn btn-sm" disabled>
{isGithubAuthenticated
? "Logging out…"
: "Waiting for login…"}
</button>
<button
className="btn btn-sm"
onClick={() => handleCancelLogin("github")}
style={{ marginLeft: 8 }}
>
Cancel
</button>
</>
) : isGithubAuthenticated ? (
<button
className="btn btn-sm"
onClick={() => handleLogout("github")}
>
Disconnect
</button>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleLogin("github")}
>
Connect
</button>
)}
</div>
{/* Show timeout message */}
{loginOutcomes["github"] === "timeout" && authActionInProgress !== "github" && (
<p className="onboarding-helper-text" style={{ marginTop: 4 }}>
Login timed out. Please try again.
</p>
)}
{/* Show failure message */}
{loginOutcomes["github"] === "failed" && authActionInProgress !== "github" && (
<p className="field-error" style={{ marginTop: 4 }}>
Login failed. Please try again.
</p>
)}
</div>
{!isGithubAuthenticated && (
<p className="onboarding-helper-text">
No worries if you're not ready connect GitHub anytime from Settings Authentication.
</p>
)}
</>
)}
</div>
)}
{step === "first-task" && (
<div className="model-onboarding-first-task">
<p className="model-onboarding-description">
Your workspace is ready. Here's how to get started:
</p>
<OnboardingDisclosure summary="What happens when I create a task?">
<p className="onboarding-helper-text">
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.
</p>
</OnboardingDisclosure>
<div className="onboarding-cta-options">
<button
className="onboarding-cta-card primary"
onClick={handleOpenNewTask}
disabled={saving}
>
<div className="cta-icon">
<Plus size={24} />
</div>
<div className="cta-content">
<strong>Create a New Task</strong>
<span>Describe what you need built and AI will work on it</span>
</div>
</button>
<button
className="onboarding-cta-card"
onClick={handleOpenGitHubImport}
disabled={saving}
>
<div className="cta-icon">
<GitPullRequest size={24} />
</div>
<div className="cta-content">
<strong>Import from GitHub</strong>
<span>Turn GitHub issues into tasks you can track here</span>
</div>
</button>
</div>
<p className="onboarding-skip-note">
You can create tasks anytime from the board, or use{" "}
<code>fn task create</code> in the terminal.
</p>
</div>
)}
{step === "complete" && (
<div className="model-onboarding-complete">
<CheckCircle size={48} className="success-icon" />
<p>
Setup complete! Head to the board to create your first task, or
explore the dashboard to see what's available.
</p>
</div>
)}
</div>
{/* Footer */}
<div className="model-onboarding-footer">
{step === "ai-setup" && (
<>
<button
className="btn btn-sm"
onClick={handleDismiss}
disabled={saving}
>
Skip for now
</button>
<button className="onboarding-skip-step-link" onClick={handleNext}>
Skip setup →
</button>
<button className="btn btn-primary" onClick={handleNext}>
Next →
</button>
</>
)}
{step === "github" && (
<>
<button className="btn btn-sm" onClick={handleBack}>
← Back
</button>
<button className="onboarding-skip-step-link" onClick={handleNext}>
Skip GitHub →
</button>
<button className="btn btn-primary" onClick={handleNext}>
Next →
</button>
</>
)}
{step === "first-task" && (
<>
<button className="btn btn-sm" onClick={handleBack}>
← Back
</button>
<button
className="btn btn-primary"
onClick={handleComplete}
disabled={saving}
>
{saving ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>Saving…</span>
</>
) : (
"Finish Setup"
)}
</button>
</>
)}
{step === "complete" && (
<button className="btn btn-primary" onClick={handleFinish}>
<CheckCircle size={16} />
<span>Get Started</span>
</button>
)}
</div>
</div>
</div>
);
}
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<UseProjectActionsResult, "handleSetupComplete" | "handleModelOnboardingComplete">;
taskHandlers: Pick<UseTaskHandlersResult, "handleModalCreate" | "handlePlanningTaskCreated" | "handlePlanningTasksCreated" | "handleSubtaskTasksCreated" | "handleGitHubImport">;
taskOperations: {
moveTask: (taskId: string, column: Column, position?: number) => Promise<Task>;
deleteTask: (taskId: string) => Promise<Task>;
mergeTask: (taskId: string) => Promise<MergeResult>;
retryTask: (taskId: string) => Promise<Task>;
duplicateTask: (taskId: string) => Promise<Task>;
};
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 && (
<ModalErrorBoundary>
<TaskDetailModal
task={modalManager.detailTask}
projectId={projectId}
tasks={tasks}
onClose={deepLink.handleDetailClose}
onOpenDetail={modalManager.openDetailTask}
onMoveTask={taskOperations.moveTask}
onDeleteTask={taskOperations.deleteTask}
onMergeTask={taskOperations.mergeTask}
onRetryTask={taskOperations.retryTask}
onDuplicateTask={taskOperations.duplicateTask}
onTaskUpdated={modalManager.updateDetailTask}
addToast={addToast}
githubTokenConfigured={settings.githubTokenConfigured}
initialTab={modalManager.detailTaskInitialTab}
/>
</ModalErrorBoundary>
)}
{modalManager.settingsOpen && (
<ModalErrorBoundary>
<SettingsModal
onClose={handleSettingsClose}
addToast={addToast}
initialSection={modalManager.settingsInitialSection}
projectId={projectId}
themeMode={settings.themeMode}
colorTheme={settings.colorTheme}
onThemeModeChange={settings.setThemeMode}
onColorThemeChange={settings.setColorTheme}
/>
</ModalErrorBoundary>
)}
<GitHubImportModal
isOpen={modalManager.githubImportOpen}
onClose={modalManager.closeGitHubImport}
onImport={taskHandlers.handleGitHubImport}
tasks={tasks}
projectId={projectId}
/>
<ModalErrorBoundary>
<PlanningModeModal
isOpen={modalManager.isPlanningOpen}
onClose={modalManager.closePlanning}
onTaskCreated={taskHandlers.handlePlanningTaskCreated}
onTasksCreated={taskHandlers.handlePlanningTasksCreated}
tasks={tasks}
initialPlan={modalManager.planningInitialPlan ?? undefined}
projectId={projectId}
resumeSessionId={modalManager.planningResumeSessionId}
/>
</ModalErrorBoundary>
<ModalErrorBoundary>
<SubtaskBreakdownModal
isOpen={modalManager.isSubtaskOpen}
onClose={modalManager.closeSubtask}
initialDescription={modalManager.subtaskInitialDescription ?? ""}
onTasksCreated={taskHandlers.handleSubtaskTasksCreated}
projectId={projectId}
resumeSessionId={modalManager.subtaskResumeSessionId}
/>
</ModalErrorBoundary>
<TerminalModal
isOpen={modalManager.terminalOpen}
onClose={modalManager.closeTerminal}
initialCommand={modalManager.terminalInitialCommand}
projectId={projectId}
/>
<ScriptsModal
isOpen={modalManager.scriptsOpen}
onClose={modalManager.closeScripts}
addToast={addToast}
onRunScript={modalManager.runScript}
projectId={projectId}
/>
{modalManager.filesOpen && (
<FileBrowserModal
initialWorkspace={modalManager.fileBrowserWorkspace}
isOpen={true}
onClose={modalManager.closeFiles}
onWorkspaceChange={modalManager.setFileWorkspace}
projectId={projectId}
/>
)}
<UsageIndicator
isOpen={modalManager.usageOpen}
onClose={modalManager.closeUsage}
projectId={projectId}
/>
{modalManager.schedulesOpen && (
<ScheduledTasksModal
onClose={modalManager.closeSchedules}
addToast={addToast}
/>
)}
<ModalErrorBoundary>
<NewTaskModal
isOpen={modalManager.newTaskModalOpen}
onClose={modalManager.closeNewTask}
tasks={tasks}
onCreateTask={taskHandlers.handleModalCreate}
addToast={addToast}
projectId={projectId}
onPlanningMode={modalManager.openPlanningWithInitialPlan}
onSubtaskBreakdown={modalManager.openSubtaskBreakdown}
/>
</ModalErrorBoundary>
<ActivityLogModal
isOpen={modalManager.activityLogOpen}
onClose={modalManager.closeActivityLog}
tasks={tasks}
projectId={projectId}
projects={projects}
currentProject={currentProject}
onOpenTaskDetail={(taskId) => {
const task = tasks.find((candidate) => candidate.id === taskId);
if (task) {
modalManager.openDetailTask(task);
}
}}
/>
<ModalErrorBoundary>
<GitManagerModal
isOpen={modalManager.gitManagerOpen}
onClose={modalManager.closeGitManager}
tasks={tasks}
addToast={addToast}
projectId={projectId}
/>
</ModalErrorBoundary>
<ModalErrorBoundary>
<WorkflowStepManager
isOpen={modalManager.workflowStepsOpen}
onClose={modalManager.closeWorkflowSteps}
addToast={addToast}
projectId={projectId}
/>
</ModalErrorBoundary>
<AgentListModal
isOpen={modalManager.agentsOpen}
onClose={modalManager.closeAgents}
addToast={addToast}
projectId={projectId}
/>
{modalManager.setupWizardOpen && (
<SetupWizardModal
onProjectRegistered={projectActions.handleSetupComplete}
onClose={modalManager.closeSetupWizard}
/>
)}
{modalManager.modelOnboardingOpen && (
<ModelOnboardingModal
onComplete={projectActions.handleModelOnboardingComplete}
addToast={addToast}
onOpenNewTask={handleOpenNewTask}
onOpenGitHubImport={handleOpenGitHubImport}
/>
)}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</>
);
}
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]);
}