1692 lines
63 KiB
TypeScript
1692 lines
63 KiB
TypeScript
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");
|
||
|
||
// Skip-state detection: derived state for informational banners
|
||
// Detects whether at least one AI provider is connected (excludes GitHub)
|
||
const hasAiProvider = authProviders.some((p) => p.id !== "github" && p.authenticated);
|
||
// True when on GitHub step but skipped AI setup (no AI provider connected)
|
||
const aiSetupSkipped = step === "github" && !hasAiProvider;
|
||
// True when on First Task step but skipped GitHub
|
||
const githubSkipped = step === "first-task" && !isGithubAuthenticated;
|
||
|
||
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>
|
||
|
||
{/* Skip-state banner: shown when AI setup was skipped */}
|
||
{aiSetupSkipped && (
|
||
<div className="onboarding-skip-banner" role="status">
|
||
<strong>No AI provider connected</strong>
|
||
<p>
|
||
AI features like task planning and code generation won't be available until you connect one.
|
||
You can set this up later in Settings.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<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>
|
||
|
||
{/* Skip-state banner: shown when GitHub was skipped */}
|
||
{githubSkipped && (
|
||
<div className="onboarding-skip-banner" role="status">
|
||
<strong>GitHub not connected</strong>
|
||
<p>
|
||
You won't be able to import issues from GitHub, but you can still create tasks manually.
|
||
Connect GitHub later from Settings.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Skip-state banner: shown when AI setup was also skipped */}
|
||
{!hasAiProvider && (
|
||
<div className="onboarding-skip-banner" role="status">
|
||
<strong>No AI provider connected</strong>
|
||
<p>
|
||
AI agents won't be able to work on tasks until you connect a provider.
|
||
Set one up later in Settings → AI Setup.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<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]);
|
||
}
|