feat(onboarding): align first-agent setup flow
5
.changeset/onboarding-first-agent-flow.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Update first-run onboarding to include an optional first-agent step and clearer temporary-agent task guidance.
|
||||
BIN
docs/screenshots/onboarding-improve/01-provider-ai-setup.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 69 KiB |
BIN
docs/screenshots/onboarding-improve/02-provider-github.png
Normal file
|
After Width: | Height: | Size: 209 KiB |
BIN
docs/screenshots/onboarding-improve/02-real-auth-step.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 64 KiB |
BIN
docs/screenshots/onboarding-improve/04-provider-agent-step.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 67 KiB |
BIN
docs/screenshots/onboarding-improve/05-provider-first-task.png
Normal file
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 145 KiB |
BIN
docs/screenshots/onboarding-improve/08-real-create-auth-step.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 148 KiB |
@@ -489,6 +489,7 @@ export function AppModals({
|
||||
onOpenGitHubImport={handleOpenGitHubImport}
|
||||
firstCreatedTask={firstCreatedTask}
|
||||
onViewTask={handleOnboardingViewTask}
|
||||
agentOnboardingEnabled={agentOnboardingEnabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/* ===== Model Onboarding Modal ===== */
|
||||
|
||||
.model-onboarding-modal {
|
||||
/*
|
||||
FNXC:Onboarding 2026-06-22-04:16:
|
||||
First-time provider/GitHub onboarding should visually match new-project onboarding, including spacing, button treatment, contrast, and persistent Discord help access.
|
||||
*/
|
||||
/* Resizable via the native CSS grip. The user's chosen size is persisted
|
||||
by useModalResizePersist. Min/max keep the dimensions sane so a value
|
||||
saved on a 4K display still renders on a laptop. */
|
||||
@@ -15,7 +19,7 @@
|
||||
}
|
||||
|
||||
.model-onboarding-modal:not([style*="width"]) {
|
||||
width: min(1100px, calc(100vw - 40px));
|
||||
width: min(960px, calc(100vw - 40px));
|
||||
}
|
||||
.model-onboarding-modal:not([style*="height"]) {
|
||||
height: min(85vh, calc(100dvh - 40px));
|
||||
@@ -25,8 +29,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: var(--space-xl) var(--space-xl) 0;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.model-onboarding-header .modal-close {
|
||||
@@ -46,12 +50,14 @@
|
||||
}
|
||||
|
||||
.model-onboarding-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.model-onboarding-title svg {
|
||||
@@ -77,7 +83,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding: 16px 24px;
|
||||
padding: var(--space-md) var(--space-xl);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@@ -157,10 +163,10 @@
|
||||
}
|
||||
|
||||
.model-onboarding-step-connector {
|
||||
width: 40px;
|
||||
width: 30px;
|
||||
height: 2px;
|
||||
background: var(--border);
|
||||
margin: 0 12px;
|
||||
margin: 0 var(--space-sm);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
@@ -170,19 +176,19 @@
|
||||
|
||||
/* Content area */
|
||||
.model-onboarding-content {
|
||||
padding: 20px 24px;
|
||||
padding: var(--space-md) var(--space-xl);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.model-onboarding-description {
|
||||
color: var(--text-muted);
|
||||
color: var(--text);
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.onboarding-helper-text {
|
||||
color: var(--text-dim);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
margin: 4px 0 0 0;
|
||||
line-height: 1.4;
|
||||
@@ -873,6 +879,19 @@
|
||||
gap: calc(var(--space-xl) - var(--space-xs));
|
||||
}
|
||||
|
||||
.model-onboarding-agent-step .setup-wizard-agent-intro {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.model-onboarding-agent-step .setup-wizard-agent-preset-description,
|
||||
.model-onboarding-agent-step .setup-wizard-agent-preview-title-row p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.model-onboarding-agent-step .setup-wizard-agent-preview-list dd {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* === Onboarding First Task Inline Form === */
|
||||
.onboarding-first-task-form {
|
||||
display: flex;
|
||||
@@ -1134,22 +1153,25 @@
|
||||
.model-onboarding-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) var(--space-xl) var(--space-lg);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.model-onboarding-footer .btn-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: var(--space-sm);
|
||||
padding: 10px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* When the footer holds only the "Get Started" button (final "complete"
|
||||
step), push it to the right edge instead of hugging the left under
|
||||
`justify-content: space-between`. */
|
||||
.model-onboarding-footer > .btn-primary:only-child {
|
||||
margin-inline-start: auto;
|
||||
.model-onboarding-help-link {
|
||||
margin-right: auto;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.onboarding-skip-step-link {
|
||||
@@ -1384,6 +1406,22 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.model-onboarding-help-link {
|
||||
margin-right: 0;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.model-onboarding-agent-step .setup-wizard-agent-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.model-onboarding-agent-step .setup-wizard-agent-preset-list {
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.onboarding-step-wrapper {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "./ModelOnboardingModal.css";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { X, Loader2, CheckCircle, Key, Zap, GitPullRequest, Rocket, Plus } from "lucide-react";
|
||||
import "./SetupWizardModal.css";
|
||||
import { lazy, Suspense, useState, useEffect, useCallback, useRef, useMemo, type KeyboardEvent } from "react";
|
||||
import { X, Loader2, CheckCircle, Key, Zap, GitPullRequest, Rocket, Plus, Sparkles, UserRound } from "lucide-react";
|
||||
import { getErrorMessage, type Task } from "@fusion/core";
|
||||
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, CustomProvider, CustomProviderConfig, OAuthDeviceCodeInfo } from "../api";
|
||||
import {
|
||||
@@ -15,8 +16,10 @@ import {
|
||||
fetchModels,
|
||||
updateGlobalSettings,
|
||||
createTask,
|
||||
createAgent,
|
||||
fetchCustomProviders,
|
||||
createCustomProvider,
|
||||
type AgentOnboardingSummary,
|
||||
} from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
@@ -36,6 +39,19 @@ import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibilit
|
||||
import { useShellConnection } from "../hooks/useShellConnection";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { AGENT_PRESETS, getPresetById } from "./agent-presets";
|
||||
import {
|
||||
buildAgentCreatePayload,
|
||||
mapOnboardingSummaryToAgentDraft,
|
||||
mapPresetToAgentDraft,
|
||||
type AgentDraftValues,
|
||||
} from "./agent-presets/agentCreatePayload";
|
||||
|
||||
const ExperimentalAgentOnboardingModal = lazy(() =>
|
||||
import("./ExperimentalAgentOnboardingModal").then((m) => ({ default: m.ExperimentalAgentOnboardingModal })),
|
||||
);
|
||||
|
||||
const mapLegacyCustomProviderToConfig = (
|
||||
provider: CustomProvider | CustomProviderConfig,
|
||||
@@ -527,6 +543,8 @@ export interface ModelOnboardingModalProps {
|
||||
firstCreatedTask?: Task | null;
|
||||
/** Optional callback when user wants to open the created task detail */
|
||||
onViewTask?: (task: Task) => void;
|
||||
/** Enables the AI interview entry point while template creation and skip remain available. */
|
||||
agentOnboardingEnabled?: boolean;
|
||||
}
|
||||
|
||||
/** Outcome states for OAuth login attempts */
|
||||
@@ -564,9 +582,19 @@ export function ModelOnboardingModal({
|
||||
onOpenGitHubImport,
|
||||
firstCreatedTask,
|
||||
onViewTask,
|
||||
agentOnboardingEnabled = false,
|
||||
}: ModelOnboardingModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const { confirm } = useConfirm();
|
||||
/*
|
||||
FNXC:Onboarding 2026-06-22-04:16:
|
||||
First-time provider/GitHub setup must pass through the same optional first-agent flow before first-task creation.
|
||||
Users can create or skip a persistent coordinating agent because Fusion automatically spawns temporary agents to plan, execute, review, and merge tasks.
|
||||
*/
|
||||
const ceoPreset = useMemo(
|
||||
() => getPresetById("ceo") ?? AGENT_PRESETS[0]!,
|
||||
[],
|
||||
);
|
||||
// Initialize from persisted state if available (allows resume from last step)
|
||||
const persistedState = getOnboardingState();
|
||||
const persistedStep = persistedState?.currentStep;
|
||||
@@ -589,6 +617,11 @@ export function ModelOnboardingModal({
|
||||
const [isCreatingFirstTask, setIsCreatingFirstTask] = useState(false);
|
||||
const [taskCreationError, setTaskCreationError] = useState<string | null>(null);
|
||||
const [inlineCreatedTask, setInlineCreatedTask] = useState<Task | null>(null);
|
||||
const [selectedAgentPresetId, setSelectedAgentPresetId] = useState(ceoPreset.id);
|
||||
const [agentDraft, setAgentDraft] = useState<AgentDraftValues>(() => mapPresetToAgentDraft(ceoPreset));
|
||||
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
|
||||
const [agentCreationError, setAgentCreationError] = useState<string | null>(null);
|
||||
const [isAgentInterviewOpen, setIsAgentInterviewOpen] = useState(false);
|
||||
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
|
||||
const [ghCliStatus, setGhCliStatus] = useState<GhCliStatus | undefined>(undefined);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
@@ -617,6 +650,7 @@ export function ModelOnboardingModal({
|
||||
const onboardingContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const modalRef = useRef<HTMLDivElement | null>(null);
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const agentErrorRef = useRef<HTMLDivElement | null>(null);
|
||||
const [loginOutcomes, setLoginOutcomes] = useState<Record<string, LoginOutcome>>({});
|
||||
const lastAutoCopiedDeviceCodesRef = useRef<Record<string, string>>({});
|
||||
const [isGithubSkipped, setIsGithubSkipped] = useState<boolean>(() => {
|
||||
@@ -670,6 +704,7 @@ export function ModelOnboardingModal({
|
||||
{ key: "ai-setup" as const, label: t("setup.stepAiSetup", "AI Setup") },
|
||||
{ key: "github" as const, label: t("setup.stepGithub", "GitHub") },
|
||||
{ key: "project-setup" as const, label: t("setup.stepProject", "Project") },
|
||||
{ key: "agent" as const, label: t("setup.stepAgent", "Agent") },
|
||||
{ key: "first-task" as const, label: t("setup.stepFirstTask", "First Task") },
|
||||
];
|
||||
|
||||
@@ -712,6 +747,12 @@ export function ModelOnboardingModal({
|
||||
previousCreatedTaskRef.current = firstCreatedTask;
|
||||
}, [firstCreatedTask]);
|
||||
|
||||
useEffect(() => {
|
||||
if (agentCreationError) {
|
||||
agentErrorRef.current?.focus();
|
||||
}
|
||||
}, [agentCreationError]);
|
||||
|
||||
// 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);
|
||||
@@ -1079,6 +1120,61 @@ export function ModelOnboardingModal({
|
||||
handleSkip();
|
||||
}, [handleSkip]);
|
||||
|
||||
const handleAgentPresetSelect = useCallback((presetId: string) => {
|
||||
const preset = getPresetById(presetId);
|
||||
if (!preset) return;
|
||||
setSelectedAgentPresetId(preset.id);
|
||||
setAgentDraft(mapPresetToAgentDraft(preset));
|
||||
setAgentCreationError(null);
|
||||
}, []);
|
||||
|
||||
const handleAgentPresetKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>, presetId: string) => {
|
||||
const currentIndex = AGENT_PRESETS.findIndex((preset) => preset.id === presetId);
|
||||
if (currentIndex < 0) return;
|
||||
|
||||
const lastIndex = AGENT_PRESETS.length - 1;
|
||||
let nextIndex: number | null = null;
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
|
||||
nextIndex = currentIndex === lastIndex ? 0 : currentIndex + 1;
|
||||
} else if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
||||
nextIndex = currentIndex === 0 ? lastIndex : currentIndex - 1;
|
||||
} else if (event.key === "Home") {
|
||||
nextIndex = 0;
|
||||
} else if (event.key === "End") {
|
||||
nextIndex = lastIndex;
|
||||
}
|
||||
|
||||
if (nextIndex === null) return;
|
||||
event.preventDefault();
|
||||
const nextPreset = AGENT_PRESETS[nextIndex];
|
||||
handleAgentPresetSelect(nextPreset.id);
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelector<HTMLButtonElement>(`[data-model-onboarding-agent-preset-id="${nextPreset.id}"]`)?.focus();
|
||||
});
|
||||
}, [handleAgentPresetSelect]);
|
||||
|
||||
const handleApplyAgentDraft = useCallback((draft: AgentOnboardingSummary) => {
|
||||
setSelectedAgentPresetId("");
|
||||
setAgentDraft(mapOnboardingSummaryToAgentDraft(draft));
|
||||
setAgentCreationError(null);
|
||||
}, []);
|
||||
|
||||
const handleCreateFirstAgent = useCallback(async () => {
|
||||
const targetProjectId = projectId?.trim();
|
||||
if (!targetProjectId || !agentDraft.name.trim()) return;
|
||||
|
||||
setIsCreatingAgent(true);
|
||||
setAgentCreationError(null);
|
||||
try {
|
||||
await createAgent(buildAgentCreatePayload(agentDraft), targetProjectId);
|
||||
handleNext();
|
||||
} catch (err) {
|
||||
setAgentCreationError(err instanceof Error ? err.message : t("setup.firstAgentCreateError", "Failed to create agent"));
|
||||
} finally {
|
||||
setIsCreatingAgent(false);
|
||||
}
|
||||
}, [agentDraft, handleNext, projectId, t]);
|
||||
|
||||
// OAuth login handler
|
||||
const handleLogin = useCallback(
|
||||
async (providerId: string) => {
|
||||
@@ -1709,6 +1805,8 @@ export function ModelOnboardingModal({
|
||||
const connectedAiProviders = aiProviders.filter((provider) => provider.authenticated);
|
||||
const hasAiProvider = connectedAiProviders.length > 0;
|
||||
const hasProjectSelected = Boolean(projectId);
|
||||
const selectedAgentPreset = selectedAgentPresetId ? getPresetById(selectedAgentPresetId) : undefined;
|
||||
const isAgentActionDisabled = isCreatingAgent || !hasProjectSelected;
|
||||
// True when on GitHub step but skipped AI setup (no AI provider connected)
|
||||
const aiSetupSkipped = step === "github" && !hasAiProvider;
|
||||
|
||||
@@ -2101,6 +2199,11 @@ export function ModelOnboardingModal({
|
||||
<Rocket size={24} /> {t("setup.titleSetUpProject", "Set Up Your Project")}
|
||||
</>
|
||||
)}
|
||||
{step === "agent" && (
|
||||
<>
|
||||
<UserRound size={24} /> {t("setup.titleCreateFirstAgent", "Create Your First Agent")} <span className="onboarding-optional-badge">{t("setup.optionalBadge", "Optional")}</span>
|
||||
</>
|
||||
)}
|
||||
{step === "first-task" && (
|
||||
<>
|
||||
<Rocket size={24} /> {t("setup.titleCreateFirstTask", "Create Your First Task")}
|
||||
@@ -2124,7 +2227,7 @@ export function ModelOnboardingModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step indicator - 4 progress steps + complete */}
|
||||
{/* Step indicator - progress steps + complete */}
|
||||
<div className="model-onboarding-steps">
|
||||
{steps.map((s, index) => {
|
||||
// A step is done/skipped only once we have progressed beyond it.
|
||||
@@ -2690,6 +2793,109 @@ export function ModelOnboardingModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "agent" && (
|
||||
<div className="setup-wizard-agent-step model-onboarding-agent-step">
|
||||
<p className="setup-wizard-agent-intro">
|
||||
{t("setup.firstAgentIntro", "You do not need to create or assign an agent for Fusion to build tasks. Fusion automatically spawns temporary agents to plan, execute, review, and merge task work. A persistent agent is optional and can coordinate work, help create tasks, and keep direction across sessions.")}
|
||||
</p>
|
||||
|
||||
{!hasProjectSelected && (
|
||||
<div className="onboarding-project-prerequisite" data-testid="onboarding-agent-project-prerequisite">
|
||||
<p className="onboarding-helper-text">
|
||||
{t("setup.projectMustBeSelectedForAgent", "Set up a project before creating your first agent. You can also skip this and create tasks without a persistent agent.")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={onOpenSetupWizard}
|
||||
data-testid="onboarding-agent-open-setup-wizard"
|
||||
>
|
||||
{t("setup.setUpProject", "Set Up Project")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="setup-wizard-agent-layout">
|
||||
<section className="setup-wizard-agent-presets" aria-labelledby="model-onboarding-first-agent-presets-heading">
|
||||
<div className="setup-wizard-agent-section-heading" id="model-onboarding-first-agent-presets-heading">
|
||||
{t("setup.firstAgentTemplates", "Templates")}
|
||||
</div>
|
||||
<div className="setup-wizard-agent-preset-list" role="radiogroup" aria-label={t("setup.firstAgentTemplates", "Templates")}>
|
||||
{AGENT_PRESETS.map((preset) => {
|
||||
const selected = selectedAgentPresetId === preset.id;
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
className={`setup-wizard-agent-preset${selected ? " selected" : ""}`}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={selected ? t("setup.selectedAgentTemplate", "{{name}} selected", { name: preset.name }) : preset.name}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
data-model-onboarding-agent-preset-id={preset.id}
|
||||
disabled={isAgentActionDisabled}
|
||||
onClick={() => handleAgentPresetSelect(preset.id)}
|
||||
onKeyDown={(event) => handleAgentPresetKeyDown(event, preset.id)}
|
||||
>
|
||||
<AgentAvatar agent={{ id: preset.id, icon: preset.icon, name: preset.name }} size={28} />
|
||||
<span className="setup-wizard-agent-preset-copy">
|
||||
<span className="setup-wizard-agent-preset-name">
|
||||
{preset.name}
|
||||
{preset.id === "ceo" && <span className="wizard-option-recommended">{t("setup.recommended", "Recommended")}</span>}
|
||||
</span>
|
||||
<span className="setup-wizard-agent-preset-description">{preset.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="setup-wizard-agent-preview" aria-labelledby="model-onboarding-first-agent-preview-heading">
|
||||
<div className="setup-wizard-agent-section-heading" id="model-onboarding-first-agent-preview-heading">
|
||||
{t("setup.firstAgentPreview", "Preview")}
|
||||
</div>
|
||||
<div className="setup-wizard-agent-preview-card">
|
||||
<div className="setup-wizard-agent-preview-title-row">
|
||||
<AgentAvatar agent={{ id: selectedAgentPresetId || "draft", icon: agentDraft.icon, name: agentDraft.name }} size={36} />
|
||||
<div>
|
||||
<h3>{agentDraft.name || t("setup.firstAgentDraftName", "Draft agent")}</h3>
|
||||
<p>{agentDraft.title || selectedAgentPreset?.title || t("setup.firstAgentCustomDraft", "Custom agent draft")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="setup-wizard-agent-preview-list">
|
||||
<div>
|
||||
<dt>{t("agents.fieldRole", "Role")}</dt>
|
||||
<dd>{agentDraft.role}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("agents.fieldInstructionsText", "Inline Instructions")}</dt>
|
||||
<dd>{agentDraft.instructionsText || t("setup.firstAgentNoInstructions", "No inline instructions yet")}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{agentOnboardingEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn setup-wizard-agent-ai-btn"
|
||||
onClick={() => setIsAgentInterviewOpen(true)}
|
||||
disabled={isAgentActionDisabled}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
<span>{t("agents.aiInterview", "AI Interview")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{agentCreationError && (
|
||||
<div className="wizard-error" role="alert" tabIndex={-1} ref={agentErrorRef}>
|
||||
{agentCreationError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "first-task" && (
|
||||
<div className="model-onboarding-first-task">
|
||||
<p className="model-onboarding-description">
|
||||
@@ -2855,6 +3061,14 @@ export function ModelOnboardingModal({
|
||||
|
||||
{/* Footer */}
|
||||
<div className="model-onboarding-footer">
|
||||
<a
|
||||
className="btn model-onboarding-help-link"
|
||||
href="https://discord.gg/ksrfuy7WYR"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("setup.needHelp", "Need help?")}
|
||||
</a>
|
||||
{step === "ai-setup" && (
|
||||
<>
|
||||
<button
|
||||
@@ -2898,6 +3112,36 @@ export function ModelOnboardingModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "agent" && (
|
||||
<>
|
||||
<button className="btn btn-sm" onClick={handleBack}>
|
||||
{t("setup.back", "← Back")}
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={handleSkip}
|
||||
disabled={isCreatingAgent}
|
||||
>
|
||||
{t("setup.skipFirstAgent", "Skip for now")}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => void handleCreateFirstAgent()}
|
||||
disabled={isAgentActionDisabled || !agentDraft.name.trim()}
|
||||
aria-busy={isCreatingAgent}
|
||||
>
|
||||
{isCreatingAgent ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span>{t("setup.creatingFirstAgent", "Creating agent...")}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{t("setup.createFirstAgent", "Create Agent")}</span>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "first-task" && !showTaskCreated && (
|
||||
<>
|
||||
<button className="btn btn-sm" onClick={handleBack}>
|
||||
@@ -2928,6 +3172,30 @@ export function ModelOnboardingModal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{agentOnboardingEnabled && isAgentInterviewOpen && (
|
||||
<ErrorBoundary
|
||||
level="modal"
|
||||
fallback={(
|
||||
<div className="wizard-error setup-wizard-agent-interview-error" role="alert">
|
||||
<span>{t("setup.firstAgentInterviewLoadError", "AI interview could not load. You can still create an agent from a template or skip this step.")}</span>
|
||||
<button type="button" className="btn" onClick={() => setIsAgentInterviewOpen(false)}>
|
||||
{t("setup.firstAgentContinueWithTemplates", "Continue with templates")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<ExperimentalAgentOnboardingModal
|
||||
isOpen={isAgentInterviewOpen}
|
||||
onClose={() => setIsAgentInterviewOpen(false)}
|
||||
onUseDraft={handleApplyAgentDraft}
|
||||
projectId={projectId}
|
||||
existingAgents={[]}
|
||||
mode="create"
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function SetupWizardModal({
|
||||
agentOnboardingEnabled = false,
|
||||
}: SetupWizardModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const helpUrl = "https://github.com/runfusion/fusion/discussions";
|
||||
const helpUrl = "https://discord.gg/ksrfuy7WYR";
|
||||
/*
|
||||
FNXC:Onboarding 2026-06-22-03:11:
|
||||
First-run project setup must offer a first persistent agent after registration, defaulting to the CEO preset while still letting users choose another template or skip creation.
|
||||
@@ -678,7 +678,7 @@ export function SetupWizardModal({
|
||||
className="btn setup-wizard-help-link"
|
||||
href={helpUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("setup.needHelp", "Need help?")}
|
||||
</a>
|
||||
|
||||
@@ -258,7 +258,7 @@ vi.mock("../../components/model-onboarding-state", () => ({
|
||||
getOnboardingCompletedAt: (...args: unknown[]) => mockGetOnboardingCompletedAt(...args),
|
||||
getSkippedSteps: (...args: unknown[]) => mockGetSkippedSteps(...args),
|
||||
getStepData: (...args: unknown[]) => mockGetStepData(...args),
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "agent", "first-task"],
|
||||
}));
|
||||
|
||||
// Mock CustomModelDropdown for onboarding modal tests
|
||||
|
||||
@@ -17,6 +17,7 @@ const mockFetchModels = vi.fn();
|
||||
const mockFetchGlobalSettings = vi.fn();
|
||||
const mockUpdateGlobalSettings = vi.fn();
|
||||
const mockCreateTask = vi.fn();
|
||||
const mockCreateAgent = vi.fn();
|
||||
const mockFetchCustomProviders = vi.fn();
|
||||
const mockCreateCustomProvider = vi.fn();
|
||||
const mockFetchCursorCliStatus = vi.fn();
|
||||
@@ -36,6 +37,7 @@ vi.mock("../../api", () => ({
|
||||
fetchGlobalSettings: (...args: unknown[]) => mockFetchGlobalSettings(...args),
|
||||
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
|
||||
createTask: (...args: unknown[]) => mockCreateTask(...args),
|
||||
createAgent: (...args: unknown[]) => mockCreateAgent(...args),
|
||||
fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args),
|
||||
createCustomProvider: (...args: unknown[]) => mockCreateCustomProvider(...args),
|
||||
fetchCursorCliStatus: (...args: unknown[]) => mockFetchCursorCliStatus(...args),
|
||||
@@ -98,7 +100,7 @@ vi.mock("../model-onboarding-state", () => ({
|
||||
markStepSkipped: (...args: unknown[]) => mockMarkStepSkipped(...args),
|
||||
getSkippedSteps: (...args: unknown[]) => mockGetSkippedSteps(...args),
|
||||
getStepData: (...args: unknown[]) => mockGetStepData(...args),
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "agent", "first-task"],
|
||||
}));
|
||||
|
||||
const mockTrackOnboardingEvent = vi.fn();
|
||||
@@ -138,6 +140,8 @@ vi.mock("lucide-react", async (importOriginal) => {
|
||||
GitPullRequest: () => <span data-testid="icon-git-pull-request">GitPullRequest</span>,
|
||||
Rocket: () => <span data-testid="icon-rocket">Rocket</span>,
|
||||
Plus: () => <span data-testid="icon-plus">Plus</span>,
|
||||
Sparkles: () => <span data-testid="icon-sparkles">Sparkles</span>,
|
||||
UserRound: () => <span data-testid="icon-user-round">UserRound</span>,
|
||||
ChevronRight: () => <span data-testid="icon-chevron-right">ChevronRight</span>,
|
||||
};
|
||||
});
|
||||
@@ -180,6 +184,10 @@ async function navigateToProjectSetupStep() {
|
||||
async function navigateToFirstTaskStep() {
|
||||
await navigateToProjectSetupStep();
|
||||
fireEvent.click(screen.getByText("Next →"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Agent")).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Skip for now"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeTruthy();
|
||||
});
|
||||
@@ -194,6 +202,7 @@ beforeEach(() => {
|
||||
mockFetchGlobalSettings.mockResolvedValue({});
|
||||
mockUpdateGlobalSettings.mockResolvedValue({});
|
||||
mockCreateTask.mockResolvedValue({ id: "FN-TEST", description: "test task" });
|
||||
mockCreateAgent.mockResolvedValue({ id: "agent-1" });
|
||||
mockFetchCustomProviders.mockResolvedValue({ providers: [] });
|
||||
mockCreateCustomProvider.mockResolvedValue({ provider: {} });
|
||||
mockLoginProvider.mockResolvedValue({ url: "https://auth.example.com/login" });
|
||||
@@ -1869,6 +1878,12 @@ describe("ModelOnboardingModal", () => {
|
||||
|
||||
fireEvent.click(screen.getByText("Next →"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Skip for now"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeTruthy();
|
||||
});
|
||||
@@ -2450,6 +2465,12 @@ describe("ModelOnboardingModal", () => {
|
||||
|
||||
fireEvent.click(screen.getByText("← Back"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("← Back"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Set Up Your Project")).toBeTruthy();
|
||||
});
|
||||
@@ -2459,6 +2480,7 @@ describe("ModelOnboardingModal", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Connect GitHub")).toBeTruthy();
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2582,7 +2604,7 @@ describe("ModelOnboardingModal", () => {
|
||||
expect(aiSetupIndicator).toHaveClass("done");
|
||||
expect(githubIndicator).toHaveClass("done");
|
||||
expect(firstTaskIndicator).toHaveClass("done");
|
||||
expect(document.querySelectorAll(".model-onboarding-step-connector.done")).toHaveLength(3);
|
||||
expect(document.querySelectorAll(".model-onboarding-step-connector.done")).toHaveLength(4);
|
||||
|
||||
// Click Get Started to close
|
||||
fireEvent.click(screen.getByText("Get Started"));
|
||||
@@ -2877,6 +2899,12 @@ describe("ModelOnboardingModal", () => {
|
||||
// Navigate back to see if the model dropdown has the saved value
|
||||
fireEvent.click(screen.getByText("← Back"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("← Back"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Set Up Your Project")).toBeTruthy();
|
||||
});
|
||||
@@ -3209,6 +3237,13 @@ describe("ModelOnboardingModal", () => {
|
||||
|
||||
fireEvent.click(screen.getByText("Next →"));
|
||||
|
||||
// Then advance to optional Agent step before First Task
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Skip for now"));
|
||||
|
||||
// Then advance to First Task step
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeTruthy();
|
||||
@@ -3273,6 +3308,11 @@ describe("ModelOnboardingModal", () => {
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Next →"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Skip for now"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeTruthy();
|
||||
});
|
||||
@@ -3320,6 +3360,12 @@ describe("ModelOnboardingModal", () => {
|
||||
|
||||
fireEvent.click(screen.getByText("Next →"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Skip for now"));
|
||||
|
||||
// Then advance to First Task step
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeTruthy();
|
||||
@@ -4572,4 +4618,3 @@ describe("Custom providers disclosure", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { OnboardingResumeCard } from "../OnboardingResumeCard";
|
||||
// Mock the model-onboarding-state module
|
||||
vi.mock("../model-onboarding-state", () => ({
|
||||
getOnboardingResumeStep: vi.fn(),
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "agent", "first-task"],
|
||||
}));
|
||||
|
||||
const mockTrackOnboardingEvent = vi.fn();
|
||||
@@ -184,7 +184,7 @@ describe("OnboardingResumeCard", () => {
|
||||
});
|
||||
render(<OnboardingResumeCard onResume={vi.fn()} />);
|
||||
// Uses singular "step" for 1 completed
|
||||
expect(screen.getByText(/1 of 4 step complete/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 of 5 step complete/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows completed step count text with 2 completed steps (plural)", () => {
|
||||
@@ -195,7 +195,7 @@ describe("OnboardingResumeCard", () => {
|
||||
});
|
||||
render(<OnboardingResumeCard onResume={vi.fn()} />);
|
||||
// Uses plural "steps" for 2 completed
|
||||
expect(screen.getByText(/2 of 4 steps complete/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 of 5 steps complete/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ vi.mock("../model-onboarding-state", () => ({
|
||||
isOnboardingCompleted: (...args: unknown[]) => mockIsOnboardingCompleted(...args),
|
||||
isPostOnboardingDismissed: (...args: unknown[]) => mockIsPostOnboardingDismissed(...args),
|
||||
dismissPostOnboardingRecommendations: (...args: unknown[]) => mockDismissPostOnboardingRecommendations(...args),
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "agent", "first-task"],
|
||||
}));
|
||||
|
||||
vi.mock("../PluginSlot", () => ({
|
||||
|
||||
@@ -162,7 +162,7 @@ describe("SetupWizardModal", () => {
|
||||
expect(screen.getByText("Set Token & Continue")).toBeDefined();
|
||||
expect(screen.getByRole("link", { name: "Need help?" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/runfusion/fusion/discussions"
|
||||
"https://discord.gg/ksrfuy7WYR"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ vi.mock("../../components/model-onboarding-state", () => ({
|
||||
getOnboardingCompletedAt: () => null,
|
||||
getSkippedSteps: () => [],
|
||||
getStepData: () => null,
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "agent", "first-task"],
|
||||
}));
|
||||
|
||||
vi.mock("../../components/Board", () => ({
|
||||
|
||||
@@ -17,6 +17,7 @@ const mockSaveApiKey = vi.fn();
|
||||
const mockClearApiKey = vi.fn();
|
||||
const mockUpdateGlobalSettings = vi.fn();
|
||||
const mockCreateTask = vi.fn();
|
||||
const mockCreateAgent = vi.fn();
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
|
||||
@@ -28,6 +29,7 @@ vi.mock("../../api", () => ({
|
||||
clearApiKey: (...args: unknown[]) => mockClearApiKey(...args),
|
||||
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
|
||||
createTask: (...args: unknown[]) => mockCreateTask(...args),
|
||||
createAgent: (...args: unknown[]) => mockCreateAgent(...args),
|
||||
}));
|
||||
|
||||
const mockGetOnboardingState = vi.fn();
|
||||
@@ -56,7 +58,7 @@ vi.mock("../model-onboarding-state", () => ({
|
||||
isOnboardingCompleted: (...args: unknown[]) => mockIsOnboardingCompleted(...args),
|
||||
isPostOnboardingDismissed: (...args: unknown[]) => mockIsPostOnboardingDismissed(...args),
|
||||
dismissPostOnboardingRecommendations: (...args: unknown[]) => mockDismissPostOnboardingRecommendations(...args),
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "agent", "first-task"],
|
||||
}));
|
||||
|
||||
vi.mock("../CustomModelDropdown", () => ({
|
||||
@@ -196,7 +198,8 @@ async function advanceThroughSteps(_renderResult: RenderResult, actions: Array<"
|
||||
} else {
|
||||
const skipButton =
|
||||
screen.queryByRole("button", { name: "Skip setup →" })
|
||||
?? screen.queryByRole("button", { name: "Skip GitHub →" });
|
||||
?? screen.queryByRole("button", { name: "Skip GitHub →" })
|
||||
?? screen.queryByRole("button", { name: "Skip for now" });
|
||||
|
||||
expect(skipButton).toBeTruthy();
|
||||
fireEvent.click(skipButton!);
|
||||
@@ -334,6 +337,7 @@ beforeEach(() => {
|
||||
mockClearApiKey.mockResolvedValue({ success: true });
|
||||
mockUpdateGlobalSettings.mockResolvedValue({});
|
||||
mockCreateTask.mockResolvedValue(createdTaskMock);
|
||||
mockCreateAgent.mockResolvedValue({ id: "agent-1" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -539,6 +543,11 @@ describe("onboarding flow integration", () => {
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next →" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Agent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Skip for now" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
|
||||
});
|
||||
@@ -586,7 +595,7 @@ describe("onboarding flow integration", () => {
|
||||
it("dismissal flow: dismissing on first-task step saves skipped GitHub state", async () => {
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "skip", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "skip", "next", "skip"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
|
||||
@@ -626,7 +635,7 @@ describe("onboarding flow integration", () => {
|
||||
|
||||
expect(screen.getByText("Continue Setup")).toBeInTheDocument();
|
||||
expect(screen.getByText(/GitHub/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 of 4 step complete/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 of 5 step complete/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -682,7 +691,7 @@ describe("onboarding flow integration", () => {
|
||||
expect(screen.getByText("Connect GitHub")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "skip"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
|
||||
@@ -802,7 +811,7 @@ describe("onboarding flow integration", () => {
|
||||
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next", "skip"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
|
||||
@@ -821,7 +830,7 @@ describe("onboarding flow integration", () => {
|
||||
expect(screen.getByText("Anthropic")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await advanceThroughSteps(renderResult, ["skip", "skip", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["skip", "skip", "next", "skip"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
|
||||
@@ -842,7 +851,7 @@ describe("onboarding flow integration", () => {
|
||||
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next", "skip"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
|
||||
@@ -869,7 +878,7 @@ describe("onboarding flow integration", () => {
|
||||
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next", "skip"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
|
||||
@@ -881,7 +890,7 @@ describe("onboarding flow integration", () => {
|
||||
it("skip warnings: Import from GitHub CTA shows connection requirement note when GitHub not connected", async () => {
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next", "skip"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
|
||||
@@ -900,7 +909,7 @@ describe("onboarding flow integration", () => {
|
||||
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next", "skip"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
|
||||
@@ -918,7 +927,7 @@ describe("onboarding flow integration", () => {
|
||||
it("task creation flow: inline task creation shows success state and marks onboarding complete", async () => {
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next", "skip"]);
|
||||
await simulateFirstTaskCreation(renderResult, "Ship onboarding telemetry");
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -938,7 +947,7 @@ describe("onboarding flow integration", () => {
|
||||
it("task creation flow: View Task button navigates and completes onboarding", async () => {
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next", "skip"]);
|
||||
await simulateFirstTaskCreation(renderResult, "View task flow");
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -989,6 +998,12 @@ describe("onboarding flow integration", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next →" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Agent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Skip for now" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
|
||||
});
|
||||
@@ -1013,7 +1028,7 @@ describe("onboarding flow integration", () => {
|
||||
it("task creation flow: Finish Setup button (without creating task) completes onboarding", async () => {
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next", "skip"]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Finish Setup" }));
|
||||
|
||||
@@ -1027,7 +1042,7 @@ describe("onboarding flow integration", () => {
|
||||
it("task creation flow: Create a New Task CTA completes onboarding and triggers external task dialog", async () => {
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next", "skip"]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Create a New Task/i }));
|
||||
|
||||
@@ -1049,7 +1064,7 @@ describe("onboarding flow integration", () => {
|
||||
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next", "skip"]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Import from GitHub/i }));
|
||||
|
||||
@@ -1064,7 +1079,7 @@ describe("onboarding flow integration", () => {
|
||||
it("task creation flow: validation error on empty description does not call createTask", async () => {
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next", "skip"]);
|
||||
|
||||
fireEvent.click(screen.getByTestId("onboarding-first-task-submit"));
|
||||
|
||||
@@ -1076,7 +1091,7 @@ describe("onboarding flow integration", () => {
|
||||
mockCreateTask.mockRejectedValueOnce(new Error("Task API unavailable"));
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next", "skip"]);
|
||||
await simulateFirstTaskCreation(renderResult, "Retryable task");
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1090,7 +1105,7 @@ describe("onboarding flow integration", () => {
|
||||
it("task creation flow: Get Started button on complete step closes the modal", async () => {
|
||||
const renderResult = renderModal();
|
||||
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next"]);
|
||||
await advanceThroughSteps(renderResult, ["next", "next", "next", "skip"]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Finish Setup" }));
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* from where they left off if they dismiss the modal without completing.
|
||||
*/
|
||||
|
||||
export type OnboardingStep = "ai-setup" | "github" | "project-setup" | "first-task" | "complete";
|
||||
export type OnboardingStep = "ai-setup" | "github" | "project-setup" | "agent" | "first-task" | "complete";
|
||||
|
||||
interface OnboardingState {
|
||||
currentStep: OnboardingStep | string; // string allows for future unknown steps
|
||||
@@ -42,7 +42,7 @@ const DEFAULT_POST_ONBOARDING_DISMISSED_AT: string | undefined = undefined;
|
||||
* Ordered onboarding flow steps before completion.
|
||||
* Keep this list in sync with ModelOnboardingModal's stepper rendering and navigation.
|
||||
*/
|
||||
export const ONBOARDING_FLOW_STEPS = ["ai-setup", "github", "project-setup", "first-task"] as const;
|
||||
export const ONBOARDING_FLOW_STEPS = ["ai-setup", "github", "project-setup", "agent", "first-task"] as const;
|
||||
|
||||
/**
|
||||
* Step labels for display in the resume card.
|
||||
@@ -52,6 +52,7 @@ export const ONBOARDING_STEP_LABELS: Record<OnboardingStep, string> = {
|
||||
"ai-setup": "AI Setup",
|
||||
github: "GitHub",
|
||||
"project-setup": "Project",
|
||||
agent: "Agent",
|
||||
"first-task": "First Task",
|
||||
complete: "Complete",
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock("../../api", () => ({
|
||||
|
||||
vi.mock("../../components/model-onboarding-state", () => ({
|
||||
isOnboardingCompleted: (...args: unknown[]) => mockIsOnboardingCompleted(...args),
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "agent", "first-task"],
|
||||
}));
|
||||
|
||||
vi.mock("../../components/onboarding-events", () => ({
|
||||
|
||||
@@ -6679,6 +6679,7 @@
|
||||
"profileName": "Profile name",
|
||||
"projectDirectory": "Project Directory",
|
||||
"projectMustBeSelected": "A project must be selected before you can create tasks or import from GitHub.",
|
||||
"projectMustBeSelectedForAgent": "Set up a project before creating your first agent. You can also skip this and create tasks without a persistent agent.",
|
||||
"projectName": "Project Name",
|
||||
"projectNameHintClone": "By default this follows the destination folder name unless you edit it.",
|
||||
"projectNameHintExisting": "By default this follows the selected directory name unless you edit it.",
|
||||
@@ -6760,6 +6761,7 @@
|
||||
"statusNotConnected": "Not connected",
|
||||
"statusRetry": "Retry",
|
||||
"statusSkipped": "Skipped",
|
||||
"stepAgent": "Agent",
|
||||
"stepAiSetup": "AI Setup",
|
||||
"stepFirstTask": "First Task",
|
||||
"stepGithub": "GitHub",
|
||||
@@ -6773,6 +6775,7 @@
|
||||
"titleAiSetup": "Set Up AI",
|
||||
"titleAllSet": "All Set!",
|
||||
"titleConnectGitHub": "Connect GitHub",
|
||||
"titleCreateFirstAgent": "Create Your First Agent",
|
||||
"titleCreateFirstTask": "Create Your First Task",
|
||||
"titleSetUpProject": "Set Up Your Project",
|
||||
"tokenEnvVar": "The token was set via the {{env}} environment variable when starting the dashboard.",
|
||||
|
||||
@@ -6679,6 +6679,7 @@
|
||||
"profileName": "Nombre del perfil",
|
||||
"projectDirectory": "Directorio del proyecto",
|
||||
"projectMustBeSelected": "Debes seleccionar un proyecto antes de crear tareas o importar desde GitHub.",
|
||||
"projectMustBeSelectedForAgent": "",
|
||||
"projectName": "Nombre del proyecto",
|
||||
"projectNameHintClone": "De forma predeterminada, esto sigue el nombre de la carpeta de destino a menos que lo edite.",
|
||||
"projectNameHintExisting": "De forma predeterminada, esto sigue el nombre del directorio seleccionado a menos que lo edite.",
|
||||
@@ -6760,6 +6761,7 @@
|
||||
"statusNotConnected": "No conectado",
|
||||
"statusRetry": "Reintentar",
|
||||
"statusSkipped": "Omitido",
|
||||
"stepAgent": "",
|
||||
"stepAiSetup": "Configuración IA",
|
||||
"stepFirstTask": "Primera tarea",
|
||||
"stepGithub": "GitHub",
|
||||
@@ -6773,6 +6775,7 @@
|
||||
"titleAiSetup": "Configurar IA",
|
||||
"titleAllSet": "¡Todo listo!",
|
||||
"titleConnectGitHub": "Conectar GitHub",
|
||||
"titleCreateFirstAgent": "",
|
||||
"titleCreateFirstTask": "Crear tu primera tarea",
|
||||
"titleSetUpProject": "Configurar tu proyecto",
|
||||
"tokenEnvVar": "El token se estableció a través de la variable de entorno {{env}} al iniciar el panel.",
|
||||
|
||||
@@ -6679,6 +6679,7 @@
|
||||
"profileName": "Nom du profil",
|
||||
"projectDirectory": "Répertoire du projet",
|
||||
"projectMustBeSelected": "Un projet doit être sélectionné avant de pouvoir créer des tâches ou importer depuis GitHub.",
|
||||
"projectMustBeSelectedForAgent": "",
|
||||
"projectName": "Nom du projet",
|
||||
"projectNameHintClone": "Par défaut, cela suit le nom du dossier de destination sauf si vous l'éditez.",
|
||||
"projectNameHintExisting": "Par défaut, cela suit le nom du répertoire sélectionné sauf si vous l'éditez.",
|
||||
@@ -6760,6 +6761,7 @@
|
||||
"statusNotConnected": "Non connecté",
|
||||
"statusRetry": "Réessayer",
|
||||
"statusSkipped": "Ignoré",
|
||||
"stepAgent": "",
|
||||
"stepAiSetup": "Configuration IA",
|
||||
"stepFirstTask": "Première tâche",
|
||||
"stepGithub": "GitHub",
|
||||
@@ -6773,6 +6775,7 @@
|
||||
"titleAiSetup": "Configurer l'IA",
|
||||
"titleAllSet": "C'est parti !",
|
||||
"titleConnectGitHub": "Connecter GitHub",
|
||||
"titleCreateFirstAgent": "",
|
||||
"titleCreateFirstTask": "Créer votre première tâche",
|
||||
"titleSetUpProject": "Configurer votre projet",
|
||||
"tokenEnvVar": "Le jeton a été défini via la variable d'environnement {{env}} au démarrage du tableau de bord.",
|
||||
|
||||
@@ -6679,6 +6679,7 @@
|
||||
"profileName": "프로필 이름",
|
||||
"projectDirectory": "프로젝트 디렉터리",
|
||||
"projectMustBeSelected": "작업을 생성하거나 GitHub에서 가져오려면 먼저 프로젝트를 선택해야 합니다.",
|
||||
"projectMustBeSelectedForAgent": "",
|
||||
"projectName": "프로젝트 이름",
|
||||
"projectNameHintClone": "기본적으로 대상 폴더 이름을 따르며, 직접 편집할 수 있습니다.",
|
||||
"projectNameHintExisting": "기본적으로 선택된 디렉터리 이름을 따르며, 직접 편집할 수 있습니다.",
|
||||
@@ -6760,6 +6761,7 @@
|
||||
"statusNotConnected": "연결되지 않음",
|
||||
"statusRetry": "재시도",
|
||||
"statusSkipped": "건너뜀",
|
||||
"stepAgent": "",
|
||||
"stepAiSetup": "AI 설정",
|
||||
"stepFirstTask": "첫 번째 작업",
|
||||
"stepGithub": "GitHub",
|
||||
@@ -6773,6 +6775,7 @@
|
||||
"titleAiSetup": "AI 설정",
|
||||
"titleAllSet": "모두 완료!",
|
||||
"titleConnectGitHub": "GitHub 연결",
|
||||
"titleCreateFirstAgent": "",
|
||||
"titleCreateFirstTask": "첫 번째 작업 만들기",
|
||||
"titleSetUpProject": "프로젝트 설정",
|
||||
"tokenEnvVar": "대시보드 시작 시 {{env}} 환경 변수를 통해 토큰이 설정되었습니다.",
|
||||
|
||||
@@ -6679,6 +6679,7 @@
|
||||
"profileName": "配置文件名称",
|
||||
"projectDirectory": "项目目录",
|
||||
"projectMustBeSelected": "在创建任务或从 GitHub 导入之前,必须先选择一个项目。",
|
||||
"projectMustBeSelectedForAgent": "",
|
||||
"projectName": "项目名称",
|
||||
"projectNameHintClone": "默认情况下,这遵循目标文件夹名称,除非您编辑它。",
|
||||
"projectNameHintExisting": "默认情况下,这遵循选定的目录名称,除非您编辑它。",
|
||||
@@ -6760,6 +6761,7 @@
|
||||
"statusNotConnected": "未连接",
|
||||
"statusRetry": "重试",
|
||||
"statusSkipped": "已跳过",
|
||||
"stepAgent": "",
|
||||
"stepAiSetup": "AI 设置",
|
||||
"stepFirstTask": "第一个任务",
|
||||
"stepGithub": "GitHub",
|
||||
@@ -6773,6 +6775,7 @@
|
||||
"titleAiSetup": "设置 AI",
|
||||
"titleAllSet": "一切就绪!",
|
||||
"titleConnectGitHub": "连接 GitHub",
|
||||
"titleCreateFirstAgent": "",
|
||||
"titleCreateFirstTask": "创建您的第一个任务",
|
||||
"titleSetUpProject": "设置您的项目",
|
||||
"tokenEnvVar": "启动仪表板时通过 {{env}} 环境变量设置令牌。",
|
||||
|
||||
@@ -6679,6 +6679,7 @@
|
||||
"profileName": "設定檔名稱",
|
||||
"projectDirectory": "專案目錄",
|
||||
"projectMustBeSelected": "在建立任務或從 GitHub 匯入之前,必須先選擇一個專案。",
|
||||
"projectMustBeSelectedForAgent": "",
|
||||
"projectName": "專案名稱",
|
||||
"projectNameHintClone": "預設情況下,除非您編輯,否則這遵循目的地資料夾名稱。",
|
||||
"projectNameHintExisting": "預設情況下,除非您編輯,否則這遵循選定的目錄名稱。",
|
||||
@@ -6760,6 +6761,7 @@
|
||||
"statusNotConnected": "未連線",
|
||||
"statusRetry": "重試",
|
||||
"statusSkipped": "已略過",
|
||||
"stepAgent": "",
|
||||
"stepAiSetup": "AI 設定",
|
||||
"stepFirstTask": "第一個任務",
|
||||
"stepGithub": "GitHub",
|
||||
@@ -6773,6 +6775,7 @@
|
||||
"titleAiSetup": "設定 AI",
|
||||
"titleAllSet": "一切就緒!",
|
||||
"titleConnectGitHub": "連線 GitHub",
|
||||
"titleCreateFirstAgent": "",
|
||||
"titleCreateFirstTask": "建立您的第一個任務",
|
||||
"titleSetUpProject": "設定您的專案",
|
||||
"tokenEnvVar": "啟動儀表板時通過 {{env}} 環境變數設定令牌。",
|
||||
|
||||
3
packages/i18n/src/resources.d.ts
vendored
@@ -6683,6 +6683,7 @@ export default interface Resources {
|
||||
"profileName": "Profile name",
|
||||
"projectDirectory": "Project Directory",
|
||||
"projectMustBeSelected": "A project must be selected before you can create tasks or import from GitHub.",
|
||||
"projectMustBeSelectedForAgent": "Set up a project before creating your first agent. You can also skip this and create tasks without a persistent agent.",
|
||||
"projectName": "Project Name",
|
||||
"projectNameHintClone": "By default this follows the destination folder name unless you edit it.",
|
||||
"projectNameHintExisting": "By default this follows the selected directory name unless you edit it.",
|
||||
@@ -6765,6 +6766,7 @@ export default interface Resources {
|
||||
"statusNotConnected": "Not connected",
|
||||
"statusRetry": "Retry",
|
||||
"statusSkipped": "Skipped",
|
||||
"stepAgent": "Agent",
|
||||
"stepAiSetup": "AI Setup",
|
||||
"stepFirstTask": "First Task",
|
||||
"stepGithub": "GitHub",
|
||||
@@ -6778,6 +6780,7 @@ export default interface Resources {
|
||||
"titleAiSetup": "Set Up AI",
|
||||
"titleAllSet": "All Set!",
|
||||
"titleConnectGitHub": "Connect GitHub",
|
||||
"titleCreateFirstAgent": "Create Your First Agent",
|
||||
"titleCreateFirstTask": "Create Your First Task",
|
||||
"titleSetUpProject": "Set Up Your Project",
|
||||
"tokenEnvVar": "The token was set via the {{env}} environment variable when starting the dashboard.",
|
||||
|
||||