feat(FN-1703): merge fusion/fn-1703

This commit is contained in:
gsxdsm
2026-04-14 11:28:52 -07:00
parent 4bb5365cde
commit 81b662b526
7 changed files with 1121 additions and 225 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { X, Loader2, CheckCircle, Key, Zap } from "lucide-react";
import { X, Loader2, CheckCircle, Key, Zap, GitPullRequest, Rocket, Plus } from "lucide-react";
import type { AuthProvider, ModelInfo } from "../api";
import {
fetchAuthStatus,
@@ -18,23 +18,30 @@ export interface ModelOnboardingModalProps {
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;
}
type OnboardingStep = "providers" | "model" | "complete";
type OnboardingStep = "ai-setup" | "github" | "first-task" | "complete";
/**
* First-run onboarding modal that guides users through:
* 1. Provider credential setup (OAuth login or API key entry)
* 2. Default model selection
* 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) {
const [isOpen, setIsOpen] = useState(true);
const [step, setStep] = useState<OnboardingStep>("providers");
const [step, setStep] = useState<OnboardingStep>("ai-setup");
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
const [authLoading, setAuthLoading] = useState(true);
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
@@ -45,6 +52,16 @@ export function ModelOnboardingModal({
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 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);
// Load auth providers
const loadAuthStatus = useCallback(async () => {
try {
@@ -72,17 +89,10 @@ export function ModelOnboardingModal({
);
}, [loadAuthStatus, loadModels]);
// Check if we can skip the providers step (already authenticated)
const hasAuthenticatedProvider = authProviders.some((p) => p.authenticated);
// Auto-advance to model step when provider is authenticated
useEffect(() => {
if (!authLoading && hasAuthenticatedProvider && step === "providers") {
// Small delay to let the user see the success state
const timer = setTimeout(() => setStep("model"), 600);
return () => clearTimeout(timer);
}
}, [authLoading, hasAuthenticatedProvider, step]);
// 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(() => {
@@ -93,6 +103,24 @@ export function ModelOnboardingModal({
};
}, []);
// Navigate to next step
const handleNext = useCallback(() => {
if (step === "ai-setup") {
setStep("github");
} else if (step === "github") {
setStep("first-task");
}
}, [step]);
// Navigate to previous step
const handleBack = useCallback(() => {
if (step === "github") {
setStep("ai-setup");
} else if (step === "first-task") {
setStep("github");
}
}, [step]);
// OAuth login handler
const handleLogin = useCallback(
async (providerId: string) => {
@@ -209,12 +237,9 @@ export function ModelOnboardingModal({
);
// Handle model selection from CustomModelDropdown
const handleModelSelect = useCallback(
(value: string) => {
setSelectedModel(value);
},
[],
);
const handleModelSelect = useCallback((value: string) => {
setSelectedModel(value);
}, []);
// Complete onboarding
const handleComplete = useCallback(async () => {
@@ -256,6 +281,86 @@ export function ModelOnboardingModal({
}
}, [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);
} 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);
} 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);
@@ -281,6 +386,10 @@ export function ModelOnboardingModal({
);
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"
@@ -292,14 +401,19 @@ export function ModelOnboardingModal({
{/* Header */}
<div className="model-onboarding-header">
<h2 id="onboarding-title" className="model-onboarding-title">
{step === "providers" && (
{step === "ai-setup" && (
<>
<Zap size={24} /> Set Up AI Provider
<Zap size={24} /> Set Up AI
</>
)}
{step === "model" && (
{step === "github" && (
<>
<Zap size={24} /> Choose Default Model
<GitPullRequest size={24} /> Connect GitHub
</>
)}
{step === "first-task" && (
<>
<Rocket size={24} /> Create Your First Task
</>
)}
{step === "complete" && (
@@ -320,32 +434,42 @@ export function ModelOnboardingModal({
)}
</div>
{/* Step indicator */}
{/* Step indicator - 3 progress steps + complete */}
<div className="model-onboarding-steps">
<div
className={`model-onboarding-step-indicator${step === "providers" ? " active" : ""}${step === "model" || step === "complete" ? " done" : ""}`}
>
<span className="step-number">1</span>
<span className="step-label">Connect Provider</span>
</div>
<div className="model-onboarding-step-connector" />
<div
className={`model-onboarding-step-indicator${step === "model" ? " active" : ""}${step === "complete" ? " done" : ""}`}
>
<span className="step-number">2</span>
<span className="step-label">Select Model</span>
</div>
{steps.map((s, index) => (
<div key={s.key} className="onboarding-step-wrapper">
{index > 0 && (
<div
className={`model-onboarding-step-connector ${
index <= currentStepIndex ? "done" : ""
}`}
/>
)}
<div
className={`model-onboarding-step-indicator ${
step === s.key ? "active" : ""
} ${currentStepIndex > index ? "done" : ""}`}
>
<span className="step-number">
{currentStepIndex > index ? (
<CheckCircle size={14} />
) : (
index + 1
)}
</span>
<span className="step-label">{s.label}</span>
</div>
</div>
))}
</div>
{/* Content */}
<div className="model-onboarding-content">
{step === "providers" && (
<div className="model-onboarding-providers">
{step === "ai-setup" && (
<div className="model-onboarding-ai-setup">
<p className="model-onboarding-description">
Connect at least one AI provider to start running tasks.
{oauthProviders.length > 0 &&
apiKeyProviders.length > 0 &&
" OAuth providers open a browser for login; API key providers need a key from the provider's dashboard."}
Connect an AI provider and choose a default model. You can
authenticate via OAuth or enter an API key.
</p>
{authLoading ? (
@@ -361,7 +485,7 @@ export function ModelOnboardingModal({
) : (
<>
{/* OAuth Providers */}
{oauthProviders.map((provider) => (
{aiOauthProviders.map((provider) => (
<div key={provider.id} className="onboarding-provider-row">
<div className="onboarding-provider-info">
<strong>{provider.name}</strong>
@@ -401,7 +525,7 @@ export function ModelOnboardingModal({
))}
{/* API Key Providers */}
{apiKeyProviders.map((provider) => (
{aiApiKeyProviders.map((provider) => (
<div key={provider.id} className="onboarding-provider-row">
<div className="onboarding-provider-info">
<strong>
@@ -473,48 +597,151 @@ export function ModelOnboardingModal({
))}
</>
)}
{/* Model Selection */}
<div className="onboarding-model-section">
<h3 className="onboarding-section-title">
Default Model (Optional)
</h3>
<p className="model-onboarding-description">
Select a default model for AI tasks. You can change this later
in Settings.
</p>
{availableModels.length === 0 ? (
<div className="model-onboarding-empty">
No models available. Please configure a provider first.
</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 === "model" && (
<div className="model-onboarding-model">
{step === "github" && (
<div className="model-onboarding-github">
<p className="model-onboarding-description">
Select the default model Fusion will use for AI tasks. You can
change this later in Settings.
Connect GitHub to import issues and manage pull requests. This is
optional you can still use Fusion without GitHub.
</p>
{availableModels.length === 0 ? (
<div className="model-onboarding-empty">
No models available. Please check your provider configuration.
{!hasGithubProvider ? (
<div className="model-onboarding-github-optional">
<GitPullRequest size={48} className="optional-icon" />
<p>
GitHub integration is not configured. You can set it up later
in Settings Authentication.
</p>
<button
className="btn btn-sm"
style={{ marginTop: 8 }}
onClick={() => setStep("providers")}
onClick={() => setStep("first-task")}
>
Back to Providers
Continue without GitHub
</button>
</div>
) : (
<div className="onboarding-model-selector">
<CustomModelDropdown
models={availableModels}
value={selectedModel}
onChange={handleModelSelect}
placeholder="Select a default model…"
label="Default model"
/>
<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 ? "authenticated" : "not-authenticated"}`}
>
{isGithubAuthenticated
? "✓ Connected"
: "✗ Not connected"}
</span>
</div>
<div>
{authActionInProgress === "github" ? (
<button className="btn btn-sm" disabled>
{isGithubAuthenticated
? "Logging out…"
: "Waiting for login…"}
</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>
</div>
)}
</div>
)}
{selectedModel && (
<div className="onboarding-model-preview">
<small className="settings-muted">
Selected:{" "}
{availableModels.find((m) => m.id === selectedModel)?.name ??
selectedModel}
</small>
</div>
)}
{step === "first-task" && (
<div className="model-onboarding-first-task">
<p className="model-onboarding-description">
You're all set! What would you like to do first?
</p>
<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 a task and let AI handle the rest</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>Bring in issues from your repositories</span>
</div>
</button>
</div>
<p className="onboarding-skip-note">
You can always create tasks later from the board or use{" "}
<code>fn task create</code> from the CLI.
</p>
</div>
)}
@@ -522,8 +749,8 @@ export function ModelOnboardingModal({
<div className="model-onboarding-complete">
<CheckCircle size={48} className="success-icon" />
<p>
You're ready to start using Fusion! You can always change your
model and provider settings from the Settings panel.
You're ready to start using Fusion! Check out the dashboard to
create your first task.
</p>
</div>
)}
@@ -531,7 +758,7 @@ export function ModelOnboardingModal({
{/* Footer */}
<div className="model-onboarding-footer">
{step === "providers" && (
{step === "ai-setup" && (
<>
<button
className="btn btn-sm"
@@ -540,26 +767,26 @@ export function ModelOnboardingModal({
>
Skip for now
</button>
<button
className="btn btn-primary"
onClick={() => {
// Fetch models now that provider is authenticated
loadModels();
setStep("model");
}}
disabled={!hasAuthenticatedProvider}
>
Continue
<button className="btn btn-primary" onClick={handleNext}>
Next
</button>
</>
)}
{step === "model" && (
{step === "github" && (
<>
<button
className="btn btn-sm"
onClick={() => setStep("providers")}
>
<button className="btn btn-sm" onClick={handleBack}>
Back
</button>
<button className="btn btn-primary" onClick={handleNext}>
Next
</button>
</>
)}
{step === "first-task" && (
<>
<button className="btn btn-sm" onClick={handleBack}>
Back
</button>
<button
@@ -573,7 +800,7 @@ export function ModelOnboardingModal({
<span>Saving</span>
</>
) : (
"Complete Setup"
"Finish Setup"
)}
</button>
</>
@@ -590,3 +817,363 @@ export function ModelOnboardingModal({
</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, ToastType } 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 { MailboxModal } from "./MailboxModal";
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}
/>
<MailboxModal
isOpen={modalManager.mailboxOpen}
onClose={modalManager.closeMailbox}
projectId={projectId}
addToast={addToast}
agents={modalManager.mailboxAgents}
/>
{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} />
</>
);
}
import { useEffect, useRef } from "react";
import { fetchAuthStatus, fetchGlobalSettings } from "../api";
import type { SectionId } from "../components/SettingsModal";
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]);
}

View File

@@ -591,7 +591,7 @@ describe("App auto-open Settings on unauthenticated", () => {
// The onboarding modal should be open showing the provider step
await waitFor(() => {
expect(screen.getByText("Set Up AI Provider")).toBeTruthy();
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
// Settings modal should NOT be open
@@ -618,7 +618,7 @@ describe("App auto-open Settings on unauthenticated", () => {
expect(screen.getByText("GitHub")).toBeTruthy();
// Onboarding modal should NOT be open
expect(screen.queryByText("Set Up AI Provider")).toBeNull();
expect(screen.queryByText("Set Up AI")).toBeNull();
});
it("does NOT auto-open anything when at least one provider is authenticated and default model is set", async () => {
@@ -642,7 +642,7 @@ describe("App auto-open Settings on unauthenticated", () => {
expect(screen.queryByText("Settings")).toBeNull();
// Onboarding modal should NOT be open
expect(screen.queryByText("Set Up AI Provider")).toBeNull();
expect(screen.queryByText("Set Up AI")).toBeNull();
});
it("treats authenticated API-key providers as valid auth for onboarding checks", async () => {
@@ -663,7 +663,7 @@ describe("App auto-open Settings on unauthenticated", () => {
await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(1));
expect(screen.queryByText("Settings")).toBeNull();
expect(screen.queryByText("Set Up AI Provider")).toBeNull();
expect(screen.queryByText("Set Up AI")).toBeNull();
});
it("auto-opens onboarding when providers are authenticated but default model is missing", async () => {
@@ -684,7 +684,7 @@ describe("App auto-open Settings on unauthenticated", () => {
// Onboarding modal should be open
await waitFor(() => {
expect(screen.getByText("Set Up AI Provider")).toBeTruthy();
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
});
@@ -699,7 +699,7 @@ describe("App auto-open Settings on unauthenticated", () => {
// Settings modal should NOT be open
expect(screen.queryByText("Settings")).toBeNull();
// Onboarding modal should NOT be open
expect(screen.queryByText("Set Up AI Provider")).toBeNull();
expect(screen.queryByText("Set Up AI")).toBeNull();
});
it("re-opening Settings via gear icon defaults to Authentication tab after closing onboarding", async () => {
@@ -709,7 +709,7 @@ describe("App auto-open Settings on unauthenticated", () => {
// Wait for onboarding to auto-open
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
await waitFor(() => {
expect(screen.getByText("Set Up AI Provider")).toBeTruthy();
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
// Dismiss the onboarding modal via Skip for now button
@@ -717,7 +717,7 @@ describe("App auto-open Settings on unauthenticated", () => {
// Onboarding modal should be closed
await waitFor(() => {
expect(screen.queryByText("Set Up AI Provider")).toBeNull();
expect(screen.queryByText("Set Up AI")).toBeNull();
});
// Open settings via the gear icon button

View File

@@ -47,15 +47,22 @@ const defaultModels = [
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
];
async function clickContinueToModelStep() {
// Navigate through steps helper
async function navigateToGitHubStep() {
await waitFor(() => {
expect(screen.getByText("Continue →")).toBeTruthy();
expect(screen.getByText("Next →")).toBeTruthy();
});
fireEvent.click(screen.getByText("Continue →"));
fireEvent.click(screen.getByText("Next →"));
await waitFor(() => {
expect(screen.getByText("Choose Default Model")).toBeTruthy();
expect(screen.getByText("Connect GitHub")).toBeTruthy();
});
}
async function navigateToFirstTaskStep() {
await navigateToGitHubStep();
fireEvent.click(screen.getByText("Next →"));
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
}
@@ -71,18 +78,60 @@ beforeEach(() => {
});
describe("ModelOnboardingModal", () => {
describe("provider step", () => {
it("renders the provider step by default", async () => {
describe("step structure", () => {
it("renders the AI Setup step by default with all three step indicators", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Set Up AI Provider")).toBeTruthy();
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
expect(screen.getByText("Connect Provider")).toBeTruthy();
expect(screen.getByText("Select Model")).toBeTruthy();
// Check step indicators
expect(screen.getByText("AI Setup")).toBeTruthy();
expect(screen.getByText("GitHub")).toBeTruthy();
expect(screen.getByText("First Task")).toBeTruthy();
// Check that AI Setup step is active
expect(screen.getByText("AI Setup").closest(".model-onboarding-step-indicator")).toHaveClass("active");
});
it("shows Next button on first step, not Back", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Next →")).toBeTruthy();
});
// Back button should not exist on first step
expect(screen.queryByText("← Back")).toBeNull();
});
it("shows Skip for now button on non-terminal steps", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Skip for now")).toBeTruthy();
});
});
it("shows Back and Next buttons on middle steps", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
// Both Back and Next should be visible
expect(screen.getByText("← Back")).toBeTruthy();
expect(screen.getByText("Next →")).toBeTruthy();
});
});
describe("AI Setup step", () => {
it("shows OAuth providers with Login button", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
@@ -117,38 +166,29 @@ describe("ModelOnboardingModal", () => {
expect(screen.getByTestId("onboarding-apikey-save-openai")).toBeTruthy();
});
it("disables Continue button when no providers are authenticated", async () => {
it("shows model dropdown in AI Setup step", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Continue →")).toBeTruthy();
expect(screen.getByText("Default Model (Optional)")).toBeTruthy();
});
expect(screen.getByText("Continue →").closest("button")?.disabled).toBe(true);
expect(screen.getByTestId("mock-model-dropdown")).toBeTruthy();
});
it("supports mixed auth states across OAuth and API key providers", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" },
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
],
});
it("allows model selection in AI Setup step", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByTestId("onboarding-auth-status-openrouter")).toBeTruthy();
expect(screen.getByTestId("onboarding-auth-status-openai")).toBeTruthy();
expect(screen.getByTestId("mock-model-dropdown")).toBeTruthy();
});
expect(screen.getByTestId("onboarding-auth-status-openrouter").textContent).toContain("Key saved");
expect(screen.getByTestId("onboarding-auth-status-openai").textContent).toContain("No API key");
const dropdown = screen.getByTestId("mock-model-dropdown");
fireEvent.change(dropdown, { target: { value: "anthropic/claude-sonnet-4-5" } });
await waitFor(() => {
expect(screen.getByText("Choose Default Model")).toBeTruthy();
}, { timeout: 3000 });
expect(screen.getByText(/Claude Sonnet 4\.5/)).toBeTruthy();
});
});
it("initiates OAuth login when Login is clicked", async () => {
@@ -185,41 +225,6 @@ describe("ModelOnboardingModal", () => {
});
});
it("auto-advances to model selection after API key authentication", async () => {
mockFetchAuthStatus
.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
],
})
.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
{ id: "openai", name: "OpenAI", authenticated: true, type: "api_key" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByTestId("onboarding-apikey-input-openai")).toBeTruthy();
});
fireEvent.change(screen.getByTestId("onboarding-apikey-input-openai"), {
target: { value: "sk-api-key" },
});
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
await waitFor(() => {
expect(mockSaveApiKey).toHaveBeenCalledWith("openai", "sk-api-key");
});
await waitFor(() => {
expect(screen.getByText("Choose Default Model")).toBeTruthy();
});
});
it("shows Save button as disabled when API key input is empty", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
@@ -266,95 +271,143 @@ describe("ModelOnboardingModal", () => {
});
});
describe("model selection step", () => {
it("advances to model step when Continue is clicked with authenticated provider", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
],
});
describe("GitHub step", () => {
it("shows optional guidance when GitHub provider is not configured", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await clickContinueToModelStep();
await navigateToGitHubStep();
expect(screen.getByText(/GitHub integration is not configured/)).toBeTruthy();
expect(screen.getByText("Continue without GitHub →")).toBeTruthy();
});
it("shows model dropdown on the model step", async () => {
it("shows GitHub status and login/logout actions when GitHub provider is present", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await clickContinueToModelStep();
await navigateToGitHubStep();
expect(screen.getByTestId("mock-model-dropdown")).toBeTruthy();
// Use more specific selector to avoid matching the header title
expect(screen.getByTestId("onboarding-auth-status-github")).toBeTruthy();
expect(screen.getByText("✗ Not connected")).toBeTruthy();
expect(screen.getByText("Connect")).toBeTruthy();
});
it("allows model selection", async () => {
it("shows connected status when GitHub is authenticated", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: true, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await clickContinueToModelStep();
await navigateToGitHubStep();
const dropdown = screen.getByTestId("mock-model-dropdown");
fireEvent.change(dropdown, { target: { value: "anthropic/claude-sonnet-4-5" } });
expect(screen.getByTestId("onboarding-auth-status-github")).toBeTruthy();
expect(screen.getByText("✓ Connected")).toBeTruthy();
expect(screen.getByText("Disconnect")).toBeTruthy();
});
it("allows navigating to First Task step via Continue without GitHub", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
fireEvent.click(screen.getByText("Continue without GitHub →"));
await waitFor(() => {
expect(screen.getByText(/Claude Sonnet 4\.5/)).toBeTruthy();
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
});
it("allows going back to provider step", async () => {
it("allows navigation back to AI Setup step", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await clickContinueToModelStep();
await navigateToGitHubStep();
fireEvent.click(screen.getByText("← Back"));
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
});
});
describe("First Task step", () => {
it("shows CTA options for creating first task", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
expect(screen.getByText("Create a New Task")).toBeTruthy();
expect(screen.getByText("Import from GitHub")).toBeTruthy();
});
it("shows skip note about CLI and board creation", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
expect(screen.getByText(/fn task create/)).toBeTruthy();
});
it("allows navigation back to GitHub step", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
fireEvent.click(screen.getByText("← Back"));
await waitFor(() => {
expect(screen.getByText("Set Up AI Provider")).toBeTruthy();
expect(screen.getByText("Connect GitHub")).toBeTruthy();
});
});
});
describe("completion", () => {
it("saves model selection and marks onboarding complete", async () => {
it("completes onboarding and calls onOpenNewTask callback", async () => {
const onComplete = vi.fn();
const onOpenNewTask = vi.fn();
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={onComplete} addToast={vi.fn()} />);
await clickContinueToModelStep();
render(
<ModelOnboardingModal
onComplete={onComplete}
addToast={vi.fn()}
onOpenNewTask={onOpenNewTask}
/>
);
// Select a model
await waitFor(() => {
expect(screen.getByTestId("mock-model-dropdown")).toBeTruthy();
});
const dropdown = screen.getByTestId("mock-model-dropdown");
fireEvent.change(dropdown, { target: { value: "anthropic/claude-sonnet-4-5" } });
// Click Complete Setup
await waitFor(() => {
expect(screen.getByText("Complete Setup")).toBeTruthy();
});
// Navigate through all steps
await navigateToFirstTaskStep();
fireEvent.click(screen.getByText("Complete Setup"));
// Click Create a New Task
fireEvent.click(screen.getByText("Create a New Task"));
// Should mark onboarding complete
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
@@ -365,6 +418,69 @@ describe("ModelOnboardingModal", () => {
);
});
// Should close modal and call both callbacks
expect(onComplete).toHaveBeenCalled();
expect(onOpenNewTask).toHaveBeenCalled();
});
it("completes onboarding and calls onOpenGitHubImport callback", async () => {
const onComplete = vi.fn();
const onOpenGitHubImport = vi.fn();
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
],
});
render(
<ModelOnboardingModal
onComplete={onComplete}
addToast={vi.fn()}
onOpenGitHubImport={onOpenGitHubImport}
/>
);
// Navigate through all steps
await navigateToFirstTaskStep();
// Click Import from GitHub
fireEvent.click(screen.getByText("Import from GitHub"));
// Should mark onboarding complete
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
expect.objectContaining({
modelOnboardingComplete: true,
}),
);
});
// Should close modal and call both callbacks
expect(onComplete).toHaveBeenCalled();
expect(onOpenGitHubImport).toHaveBeenCalled();
});
it("completes with Finish Setup button (no CTA)", async () => {
const onComplete = vi.fn();
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={onComplete} addToast={vi.fn()} />);
// Navigate through all steps
await navigateToFirstTaskStep();
// Click Finish Setup
await waitFor(() => {
expect(screen.getByText("Finish Setup")).toBeTruthy();
});
fireEvent.click(screen.getByText("Finish Setup"));
// Should show completion screen
await waitFor(() => {
expect(screen.getByText("All Set!")).toBeTruthy();
@@ -377,6 +493,7 @@ describe("ModelOnboardingModal", () => {
it("completes without model selection", async () => {
const onComplete = vi.fn();
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
@@ -385,10 +502,11 @@ describe("ModelOnboardingModal", () => {
render(<ModelOnboardingModal onComplete={onComplete} addToast={vi.fn()} />);
await clickContinueToModelStep();
// Navigate through all steps without selecting model
await navigateToFirstTaskStep();
// Click Complete Setup without selecting a model
fireEvent.click(screen.getByText("Complete Setup"));
// Click Finish Setup
fireEvent.click(screen.getByText("Finish Setup"));
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
@@ -407,7 +525,7 @@ describe("ModelOnboardingModal", () => {
render(<ModelOnboardingModal onComplete={onComplete} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Set Up AI Provider")).toBeTruthy();
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
// Click the X close button
@@ -472,21 +590,6 @@ describe("ModelOnboardingModal", () => {
});
});
it("shows empty state when no models are available on model step", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
],
});
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await clickContinueToModelStep();
expect(screen.getByText(/No models available/)).toBeTruthy();
});
it("handles auth status fetch failure gracefully", async () => {
mockFetchAuthStatus.mockRejectedValueOnce(new Error("Network error"));
@@ -494,7 +597,7 @@ describe("ModelOnboardingModal", () => {
await waitFor(() => {
// Should still render the modal without crashing
expect(screen.getByText("Set Up AI Provider")).toBeTruthy();
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
});
@@ -507,5 +610,28 @@ describe("ModelOnboardingModal", () => {
expect(screen.getByText("Loading providers…")).toBeTruthy();
});
it("works without optional callbacks", async () => {
const onComplete = vi.fn();
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
],
});
// Render without onOpenNewTask and onOpenGitHubImport
render(<ModelOnboardingModal onComplete={onComplete} addToast={vi.fn()} />);
// Navigate through all steps
await navigateToFirstTaskStep();
// Click Finish Setup - should work without callbacks
fireEvent.click(screen.getByText("Finish Setup"));
await waitFor(() => {
expect(screen.getByText("All Set!")).toBeTruthy();
});
});
});
});