feat(FN-1940): add onboarding first-task success handoff

- Track the first task created from onboarding in AppModals and pass it back into the onboarding modal
- Keep onboarding open after launching New Task, then switch to a task-created success state when a task is returned
- Add success actions to view the created task or return to the dashboard, while preserving the no-task completion path
- Style the task-created success panel and add comprehensive ModelOnboardingModal tests for transition, rendering, and button behavior
This commit is contained in:
Fusion
2026-04-17 04:23:27 -07:00
committed by gsxdsm
parent b2c4fcafdc
commit db883db162
4 changed files with 460 additions and 54 deletions

View File

@@ -1,3 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import type { ProjectInfo } from "../api"; import type { ProjectInfo } from "../api";
import type { ColorTheme, Column, MergeResult, Task, TaskCreateInput, ThemeMode } from "@fusion/core"; import type { ColorTheme, Column, MergeResult, Task, TaskCreateInput, ThemeMode } from "@fusion/core";
import type { UseProjectActionsResult } from "../hooks/useProjectActions"; import type { UseProjectActionsResult } from "../hooks/useProjectActions";
@@ -75,9 +76,42 @@ export function AppModals({
onSettingsClose, onSettingsClose,
onReopenOnboarding, onReopenOnboarding,
}: AppModalsProps) { }: AppModalsProps) {
const [firstCreatedTask, setFirstCreatedTask] = useState<Task | null>(null);
// Use the override handler if provided, otherwise fall back to modalManager.closeSettings // Use the override handler if provided, otherwise fall back to modalManager.closeSettings
const handleSettingsClose = onSettingsClose ?? modalManager.closeSettings; const handleSettingsClose = onSettingsClose ?? modalManager.closeSettings;
const handleOpenNewTask = useCallback(() => {
modalManager.openNewTask();
}, [modalManager]);
const handleOpenGitHubImport = useCallback(() => {
modalManager.openGitHubImport();
}, [modalManager]);
const handleOnboardingViewTask = useCallback((task: Task) => {
setFirstCreatedTask(null);
modalManager.closeModelOnboarding();
modalManager.openDetailTask(task);
}, [modalManager]);
const handleModalCreateWithOnboardingTracking = useCallback(
async (input: TaskCreateInput): Promise<Task> => {
const task = await taskHandlers.handleModalCreate(input);
if (modalManager.modelOnboardingOpen) {
setFirstCreatedTask(task);
}
return task;
},
[taskHandlers.handleModalCreate, modalManager.modelOnboardingOpen],
);
useEffect(() => {
if (!modalManager.modelOnboardingOpen && firstCreatedTask) {
setFirstCreatedTask(null);
}
}, [modalManager.modelOnboardingOpen, firstCreatedTask]);
return ( return (
<> <>
{modalManager.detailTask && ( {modalManager.detailTask && (
@@ -193,7 +227,7 @@ export function AppModals({
isOpen={modalManager.newTaskModalOpen} isOpen={modalManager.newTaskModalOpen}
onClose={modalManager.closeNewTask} onClose={modalManager.closeNewTask}
tasks={tasks} tasks={tasks}
onCreateTask={taskHandlers.handleModalCreate} onCreateTask={handleModalCreateWithOnboardingTracking}
addToast={addToast} addToast={addToast}
projectId={projectId} projectId={projectId}
onPlanningMode={modalManager.openPlanningWithInitialPlan} onPlanningMode={modalManager.openPlanningWithInitialPlan}
@@ -253,6 +287,10 @@ export function AppModals({
<ModelOnboardingModal <ModelOnboardingModal
onComplete={projectActions.handleModelOnboardingComplete} onComplete={projectActions.handleModelOnboardingComplete}
addToast={addToast} addToast={addToast}
onOpenNewTask={handleOpenNewTask}
onOpenGitHubImport={handleOpenGitHubImport}
firstCreatedTask={firstCreatedTask}
onViewTask={handleOnboardingViewTask}
/> />
)} )}

View File

@@ -421,6 +421,10 @@ export interface ModelOnboardingModalProps {
onOpenNewTask?: () => void; onOpenNewTask?: () => void;
/** Optional callback when user wants to open GitHub import */ /** Optional callback when user wants to open GitHub import */
onOpenGitHubImport?: () => void; onOpenGitHubImport?: () => void;
/** First task created from the onboarding flow, if available */
firstCreatedTask?: Task | null;
/** Optional callback when user wants to open the created task detail */
onViewTask?: (task: Task) => void;
} }
/** Outcome states for OAuth login attempts */ /** Outcome states for OAuth login attempts */
@@ -448,6 +452,8 @@ export function ModelOnboardingModal({
addToast, addToast,
onOpenNewTask, onOpenNewTask,
onOpenGitHubImport, onOpenGitHubImport,
firstCreatedTask,
onViewTask,
}: ModelOnboardingModalProps) { }: ModelOnboardingModalProps) {
// Initialize from persisted state if available (allows resume from last step) // Initialize from persisted state if available (allows resume from last step)
const persistedState = getOnboardingState(); const persistedState = getOnboardingState();
@@ -462,6 +468,7 @@ export function ModelOnboardingModal({
const [step, setStep] = useState<OnboardingStep>(initialStep); const [step, setStep] = useState<OnboardingStep>(initialStep);
const [completedSteps, setCompletedSteps] = useState<OnboardingStep[]>(persistedCompletedSteps); const [completedSteps, setCompletedSteps] = useState<OnboardingStep[]>(persistedCompletedSteps);
const [skippedSteps, setSkippedSteps] = useState<OnboardingStep[]>(persistedSkippedSteps); const [skippedSteps, setSkippedSteps] = useState<OnboardingStep[]>(persistedSkippedSteps);
const [showTaskCreated, setShowTaskCreated] = useState(false);
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]); const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
const [authLoading, setAuthLoading] = useState(true); const [authLoading, setAuthLoading] = useState(true);
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null); const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
@@ -479,6 +486,7 @@ export function ModelOnboardingModal({
return state?.stepData?.github?.skipped === true; return state?.stepData?.github?.skipped === true;
}); });
const pollCountRef = useRef<number>(0); const pollCountRef = useRef<number>(0);
const previousCreatedTaskRef = useRef<Task | null | undefined>(firstCreatedTask);
// Initialize skippedProviders from persisted state // Initialize skippedProviders from persisted state
const [skippedProviders, setSkippedProviders] = useState<Record<string, boolean>>( const [skippedProviders, setSkippedProviders] = useState<Record<string, boolean>>(
@@ -506,6 +514,21 @@ export function ModelOnboardingModal({
} }
}, [step, completedSteps, skippedSteps]); }, [step, completedSteps, skippedSteps]);
useEffect(() => {
const hadCreatedTask = previousCreatedTaskRef.current != null;
const hasCreatedTask = firstCreatedTask != null;
if (!hadCreatedTask && hasCreatedTask) {
setShowTaskCreated(true);
}
if (!hasCreatedTask) {
setShowTaskCreated(false);
}
previousCreatedTaskRef.current = firstCreatedTask;
}, [firstCreatedTask]);
// Auto-mark unconnected providers as skipped when leaving ai-setup step // 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") // Only skip if NO providers are connected (if at least one is connected, others remain "Not connected")
const prevStepRef = useRef<OnboardingStep>(initialStep); const prevStepRef = useRef<OnboardingStep>(initialStep);
@@ -1138,11 +1161,9 @@ export function ModelOnboardingModal({
setSaving(false); setSaving(false);
} }
// Close modal and trigger callback // Keep onboarding open so task creation can hand back to a success state
setIsOpen(false);
onComplete();
onOpenNewTask?.(); onOpenNewTask?.();
}, [selectedModel, availableModels, onComplete, onOpenNewTask]); }, [selectedModel, availableModels, onOpenNewTask]);
// Handle GitHub import CTA - mark complete, close modal, then open GitHub import // Handle GitHub import CTA - mark complete, close modal, then open GitHub import
const handleOpenGitHubImport = useCallback(async () => { const handleOpenGitHubImport = useCallback(async () => {
@@ -1204,6 +1225,19 @@ export function ModelOnboardingModal({
onComplete(); onComplete();
}, [onComplete]); }, [onComplete]);
const handleViewCreatedTask = useCallback(() => {
if (!firstCreatedTask) {
return;
}
onViewTask?.(firstCreatedTask);
onComplete();
}, [firstCreatedTask, onViewTask, onComplete]);
const handleGoToDashboard = useCallback(() => {
onComplete();
}, [onComplete]);
if (!isOpen) return null; if (!isOpen) return null;
const oauthProviders = authProviders.filter( const oauthProviders = authProviders.filter(
@@ -1300,6 +1334,9 @@ export function ModelOnboardingModal({
}); });
} }
const firstCreatedTaskPreview =
firstCreatedTask?.description?.split("\n")[0]?.trim() || firstCreatedTask?.title || "";
return ( return (
<div <div
className="modal-overlay open" className="modal-overlay open"
@@ -1820,54 +1857,86 @@ export function ModelOnboardingModal({
Your workspace is ready. Here's how to get started: Your workspace is ready. Here's how to get started:
</p> </p>
<ReadinessSummary items={readinessItems} /> {showTaskCreated && firstCreatedTask ? (
<div className="onboarding-task-created">
<OnboardingDisclosure summary="What happens when I create a task?"> <CheckCircle size={56} className="success-icon" />
<p className="onboarding-helper-text"> <h3 className="onboarding-task-created__title">Your first task is ready!</h3>
A task describes something you want done. Fusion's AI agents will read <div className="onboarding-task-created__task-id">{firstCreatedTask.id}</div>
your description and work on implementing it. You can track progress on {firstCreatedTaskPreview && (
the board and review the results. <p className="onboarding-task-created__description">{firstCreatedTaskPreview}</p>
</p> )}
</OnboardingDisclosure> <p className="onboarding-task-created__hint">
Your task has been created and will appear on the board.
<div className="onboarding-cta-options"> </p>
<button <div className="onboarding-task-created__actions">
className="onboarding-cta-card primary" <button
onClick={handleOpenNewTask} type="button"
disabled={saving} className="btn btn-primary"
> onClick={handleViewCreatedTask}
<div className="cta-icon"> >
<Plus size={24} /> View Task
</button>
<button
type="button"
className="btn"
onClick={handleGoToDashboard}
>
Go to Dashboard
</button>
</div> </div>
<div className="cta-content"> </div>
<strong>Create a New Task</strong> ) : (
<span>Describe what you need built and AI will work on it</span> <>
</div> <ReadinessSummary items={readinessItems} />
</button>
<button <OnboardingDisclosure summary="What happens when I create a task?">
className={`onboarding-cta-card${!isGithubAuthenticated ? " onboarding-cta-card--disabled" : ""}`} <p className="onboarding-helper-text">
data-testid="cta-github-import" A task describes something you want done. Fusion's AI agents will read
onClick={handleOpenGitHubImport} your description and work on implementing it. You can track progress on
disabled={saving} the board and review the results.
> </p>
<div className="cta-icon"> </OnboardingDisclosure>
<GitPullRequest size={24} />
</div>
<div className="cta-content">
<strong>Import from GitHub</strong>
<span>Turn GitHub issues into tasks you can track here</span>
{!isGithubAuthenticated && (
<small className="onboarding-cta-note">Requires GitHub connection</small>
)}
</div>
</button>
</div>
<p className="onboarding-skip-note"> <div className="onboarding-cta-options">
You can create tasks anytime from the board, or use{" "} <button
<code>fn task create</code> in the terminal. className="onboarding-cta-card primary"
</p> onClick={handleOpenNewTask}
disabled={saving}
>
<div className="cta-icon">
<Plus size={24} />
</div>
<div className="cta-content">
<strong>Create a New Task</strong>
<span>Describe what you need built and AI will work on it</span>
</div>
</button>
<button
className={`onboarding-cta-card${!isGithubAuthenticated ? " onboarding-cta-card--disabled" : ""}`}
data-testid="cta-github-import"
onClick={handleOpenGitHubImport}
disabled={saving}
>
<div className="cta-icon">
<GitPullRequest size={24} />
</div>
<div className="cta-content">
<strong>Import from GitHub</strong>
<span>Turn GitHub issues into tasks you can track here</span>
{!isGithubAuthenticated && (
<small className="onboarding-cta-note">Requires GitHub connection</small>
)}
</div>
</button>
</div>
<p className="onboarding-skip-note">
You can create tasks anytime from the board, or use{" "}
<code>fn task create</code> in the terminal.
</p>
</>
)}
</div> </div>
)} )}
@@ -1916,7 +1985,7 @@ export function ModelOnboardingModal({
</> </>
)} )}
{step === "first-task" && ( {step === "first-task" && !showTaskCreated && (
<> <>
<button className="btn btn-sm" onClick={handleBack}> <button className="btn btn-sm" onClick={handleBack}>
← Back ← Back

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, act, within } from "@testing-library/react"; import { render, screen, fireEvent, waitFor, act, within } from "@testing-library/react";
import { ModelOnboardingModal } from "../ModelOnboardingModal"; import { ModelOnboardingModal } from "../ModelOnboardingModal";
import type { AuthProvider } from "../../api"; import type { AuthProvider } from "../../api";
import type { Task } from "@fusion/core";
// Mock the API module // Mock the API module
const mockFetchAuthStatus = vi.fn(); const mockFetchAuthStatus = vi.fn();
@@ -94,6 +95,12 @@ const defaultModels = [
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 }, { provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
]; ];
const createdTaskMock = {
id: "FN-0001",
title: "Initial task",
description: "Implement onboarding success flow\nAdditional details that should not render",
} as unknown as Task;
// Navigate through steps helper // Navigate through steps helper
async function navigateToGitHubStep() { async function navigateToGitHubStep() {
await waitFor(() => { await waitFor(() => {
@@ -1225,6 +1232,229 @@ describe("ModelOnboardingModal", () => {
expect(screen.getByText("Import from GitHub")).toBeTruthy(); expect(screen.getByText("Import from GitHub")).toBeTruthy();
}); });
it("shows task-created success view after firstCreatedTask transitions from null", async () => {
mockGetOnboardingState.mockReturnValue({
currentStep: "first-task",
completedSteps: ["ai-setup", "github"],
skippedSteps: [],
updatedAt: "2026-04-17T00:00:00.000Z",
dismissed: false,
completed: false,
stepData: {},
});
const { rerender } = render(
<ModelOnboardingModal
onComplete={vi.fn()}
addToast={vi.fn()}
firstCreatedTask={null}
/>,
);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
rerender(
<ModelOnboardingModal
onComplete={vi.fn()}
addToast={vi.fn()}
firstCreatedTask={createdTaskMock}
/>,
);
await waitFor(() => {
expect(screen.getByText("Your first task is ready!")).toBeTruthy();
});
expect(screen.queryByText("Create a New Task")).toBeNull();
expect(screen.getByText(createdTaskMock.id)).toBeTruthy();
expect(screen.getByText("Implement onboarding success flow")).toBeTruthy();
});
it("shows the created task ID in the success view", async () => {
mockGetOnboardingState.mockReturnValue({
currentStep: "first-task",
completedSteps: ["ai-setup", "github"],
skippedSteps: [],
updatedAt: "2026-04-17T00:00:00.000Z",
dismissed: false,
completed: false,
stepData: {},
});
const { rerender } = render(
<ModelOnboardingModal
onComplete={vi.fn()}
addToast={vi.fn()}
firstCreatedTask={null}
/>,
);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
rerender(
<ModelOnboardingModal
onComplete={vi.fn()}
addToast={vi.fn()}
firstCreatedTask={createdTaskMock}
/>,
);
await waitFor(() => {
expect(screen.getByText("FN-0001")).toBeTruthy();
});
});
it("shows the first line of created task description in the success view", async () => {
mockGetOnboardingState.mockReturnValue({
currentStep: "first-task",
completedSteps: ["ai-setup", "github"],
skippedSteps: [],
updatedAt: "2026-04-17T00:00:00.000Z",
dismissed: false,
completed: false,
stepData: {},
});
const { rerender } = render(
<ModelOnboardingModal
onComplete={vi.fn()}
addToast={vi.fn()}
firstCreatedTask={null}
/>,
);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
rerender(
<ModelOnboardingModal
onComplete={vi.fn()}
addToast={vi.fn()}
firstCreatedTask={createdTaskMock}
/>,
);
await waitFor(() => {
expect(screen.getByText("Implement onboarding success flow")).toBeTruthy();
});
});
it("View Task button calls onViewTask with task and then onComplete", async () => {
mockGetOnboardingState.mockReturnValue({
currentStep: "first-task",
completedSteps: ["ai-setup", "github"],
skippedSteps: [],
updatedAt: "2026-04-17T00:00:00.000Z",
dismissed: false,
completed: false,
stepData: {},
});
const onComplete = vi.fn();
const onViewTask = vi.fn();
const { rerender } = render(
<ModelOnboardingModal
onComplete={onComplete}
addToast={vi.fn()}
onViewTask={onViewTask}
firstCreatedTask={null}
/>,
);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
rerender(
<ModelOnboardingModal
onComplete={onComplete}
addToast={vi.fn()}
onViewTask={onViewTask}
firstCreatedTask={createdTaskMock}
/>,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "View Task" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "View Task" }));
expect(onViewTask).toHaveBeenCalledWith(createdTaskMock);
expect(onComplete).toHaveBeenCalled();
});
it("Go to Dashboard button calls onComplete", async () => {
mockGetOnboardingState.mockReturnValue({
currentStep: "first-task",
completedSteps: ["ai-setup", "github"],
skippedSteps: [],
updatedAt: "2026-04-17T00:00:00.000Z",
dismissed: false,
completed: false,
stepData: {},
});
const onComplete = vi.fn();
const onViewTask = vi.fn();
const { rerender } = render(
<ModelOnboardingModal
onComplete={onComplete}
addToast={vi.fn()}
onViewTask={onViewTask}
firstCreatedTask={null}
/>,
);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeTruthy();
});
rerender(
<ModelOnboardingModal
onComplete={onComplete}
addToast={vi.fn()}
onViewTask={onViewTask}
firstCreatedTask={createdTaskMock}
/>,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Go to Dashboard" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Go to Dashboard" }));
expect(onComplete).toHaveBeenCalled();
expect(onViewTask).not.toHaveBeenCalled();
});
it("keeps CTA cards visible and success hidden when firstCreatedTask is not provided", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} firstCreatedTask={null} />);
await navigateToFirstTaskStep();
expect(screen.getByText("Create a New Task")).toBeTruthy();
expect(screen.queryByText("Your first task is ready!")).toBeNull();
});
it("Finish Setup still transitions to complete step when no task is created", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} firstCreatedTask={null} />);
await navigateToFirstTaskStep();
fireEvent.click(screen.getByText("Finish Setup"));
await waitFor(() => {
expect(screen.getByText("All Set!")).toBeTruthy();
});
});
it("Import from GitHub card shows connection note when GitHub not connected", async () => { it("Import from GitHub card shows connection note when GitHub not connected", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />); render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
@@ -1306,7 +1536,7 @@ describe("ModelOnboardingModal", () => {
}); });
describe("completion", () => { describe("completion", () => {
it("completes onboarding and calls onOpenNewTask callback", async () => { it("marks onboarding complete and opens New Task without closing onboarding immediately", async () => {
const onComplete = vi.fn(); const onComplete = vi.fn();
const onOpenNewTask = vi.fn(); const onOpenNewTask = vi.fn();
@@ -1348,9 +1578,10 @@ describe("ModelOnboardingModal", () => {
); );
}); });
// Should close modal and call both callbacks // Should open New Task flow but keep onboarding open for success handoff
expect(onComplete).toHaveBeenCalled();
expect(onOpenNewTask).toHaveBeenCalled(); expect(onOpenNewTask).toHaveBeenCalled();
expect(onComplete).not.toHaveBeenCalled();
expect(screen.getByText("Create Your First Task")).toBeTruthy();
}); });
it("completes onboarding and calls onOpenGitHubImport callback", async () => { it("completes onboarding and calls onOpenGitHubImport callback", async () => {

View File

@@ -24499,6 +24499,65 @@ html .column.drag-over * {
font-size: 12px; font-size: 12px;
} }
/* === Onboarding Task Created Success === */
.onboarding-task-created {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-lg);
text-align: center;
padding: var(--space-xl) 0;
}
.onboarding-task-created .success-icon {
color: var(--color-success);
filter: drop-shadow(var(--glow-success));
width: calc(var(--space-xl) + var(--space-2xl));
height: calc(var(--space-xl) + var(--space-2xl));
}
.onboarding-task-created__title {
margin: 0;
font-size: calc(var(--space-lg) + (var(--space-xs) / 2));
font-weight: 600;
color: var(--text);
}
.onboarding-task-created__task-id {
font-family: var(--font-mono);
font-size: calc(var(--space-md) + (var(--space-xs) / 2));
color: var(--text-muted);
background: var(--surface);
padding: var(--space-xs) var(--space-md);
border-radius: var(--radius-md);
border: 1px solid var(--border);
}
.onboarding-task-created__description {
margin: 0;
font-size: calc(var(--space-md) + (var(--space-xs) / 4));
color: var(--text-muted);
max-width: calc(var(--space-xl) * 15);
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.onboarding-task-created__hint {
margin: 0;
font-size: calc(var(--space-md) + (var(--space-xs) / 4));
color: var(--text-dim);
line-height: 1.5;
}
.onboarding-task-created__actions {
display: flex;
gap: var(--space-sm);
justify-content: center;
width: 100%;
}
/* Onboarding Resume Card */ /* Onboarding Resume Card */
.onboarding-resume-card { .onboarding-resume-card {
display: flex; display: flex;
@@ -24794,6 +24853,15 @@ html .column.drag-over * {
.onboarding-cta-card .cta-content span { .onboarding-cta-card .cta-content span {
font-size: 12px; font-size: 12px;
} }
.onboarding-task-created__actions {
flex-direction: column;
align-items: stretch;
}
.onboarding-task-created__actions .btn {
width: 100%;
}
} }
/* ========================================================================= /* =========================================================================