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

This commit is contained in:
gsxdsm
2026-04-23 12:55:50 -07:00
parent 3b0db3e9d2
commit d3c9476ce7
10 changed files with 457 additions and 254 deletions

View File

@@ -415,7 +415,7 @@ View a centralized timeline of all task lifecycle events. Click the history icon
- **Settings API Contract**: Server-owned fields like `githubTokenConfigured` are injected on GET /settings but stripped on PUT /settings to prevent persistence to config.json
- **Notifications**: ntfy.sh integration for push notifications when tasks complete or fail
- **Authentication**: Provider management for AI model access. OAuth providers (e.g. Anthropic) use a Login/Logout flow; API-key providers (e.g. OpenRouter) show a masked key entry with Save/Clear actions. After saving or clearing a key, the auth status refreshes immediately so the authenticated badge stays in sync. Stored key values are never prefilled or displayed.
- **Onboarding Wizard**: On first dashboard launch, a multi-step onboarding wizard guides users through three steps: (1) AI Setup — authenticate with a provider and select a default model, (2) GitHub (optional) — connect GitHub for issue import and PR management, and (3) First Task — create your first task or import from GitHub. The wizard is dismissible and non-blocking — completion is tracked via the `modelOnboardingComplete` global setting. Users can skip onboarding or re-trigger it by clearing the flag in Settings.
- **Onboarding Wizard**: On first dashboard launch, a multi-step onboarding wizard guides users through three steps: (1) AI Setup — authenticate with a provider and select a default model, (2) GitHub (optional) — connect GitHub for issue import and PR management, and (3) First Task — create your first task or import from GitHub. If there is no active project, the First Task step blocks task/import actions and prompts the user to open the existing project setup wizard first. The wizard is dismissible and non-blocking — completion is tracked via the `modelOnboardingComplete` global setting. Users can skip onboarding or re-trigger it by clearing the flag in Settings.
- **Pause Controls**: Soft pause (stop new work) and hard stop (kill all agents)
- **Theming**: Light/dark/system mode toggle and 12 color themes (see Theming section below)

View File

@@ -805,7 +805,7 @@ function AppInner() {
toasts={toasts}
removeToast={removeToast}
modalManager={modalManager}
projectActions={{ handleSetupComplete, handleModelOnboardingComplete }}
projectActions={{ handleAddProject, handleSetupComplete, handleModelOnboardingComplete }}
taskHandlers={{
handleModalCreate,
handlePlanningTaskCreated,

View File

@@ -34,7 +34,7 @@ interface AppModalsProps {
toasts: Toast[];
removeToast: (id: number) => void;
modalManager: ModalManager;
projectActions: Pick<UseProjectActionsResult, "handleSetupComplete" | "handleModelOnboardingComplete">;
projectActions: Pick<UseProjectActionsResult, "handleAddProject" | "handleSetupComplete" | "handleModelOnboardingComplete">;
taskHandlers: Pick<UseTaskHandlersResult, "handleModalCreate" | "handlePlanningTaskCreated" | "handlePlanningTasksCreated" | "handleSubtaskTasksCreated" | "handleGitHubImport">;
taskOperations: {
moveTask: (taskId: string, column: Column, position?: number) => Promise<Task>;
@@ -287,6 +287,8 @@ export function AppModals({
<ModelOnboardingModal
onComplete={projectActions.handleModelOnboardingComplete}
addToast={addToast}
projectId={projectId ?? ""}
onOpenSetupWizard={projectActions.handleAddProject}
onOpenNewTask={handleOpenNewTask}
onOpenGitHubImport={handleOpenGitHubImport}
firstCreatedTask={firstCreatedTask}

View File

@@ -420,6 +420,10 @@ export interface ModelOnboardingModalProps {
onComplete: () => void;
/** Toast helper */
addToast: (message: string, type?: ToastType) => void;
/** Currently selected project ID (required for first-task actions) */
projectId?: string;
/** Optional callback to open project setup wizard when no project is selected */
onOpenSetupWizard?: () => void;
/** Optional callback when user wants to open new task creation */
onOpenNewTask?: () => void;
/** Optional callback when user wants to open GitHub import */
@@ -458,6 +462,8 @@ const MAX_POLL_CYCLES = 150;
export function ModelOnboardingModal({
onComplete,
addToast,
projectId,
onOpenSetupWizard,
onOpenNewTask,
onOpenGitHubImport,
firstCreatedTask,
@@ -1216,6 +1222,10 @@ export function ModelOnboardingModal({
// Handle GitHub import CTA - mark complete, close modal, then open GitHub import
const handleOpenGitHubImport = useCallback(async () => {
if (!isGitHubReady) {
return;
}
// First complete the onboarding
setSaving(true);
try {
@@ -1229,7 +1239,7 @@ export function ModelOnboardingModal({
onComplete();
trackOnboardingEvent("onboarding:open-github-import", {});
onOpenGitHubImport?.();
}, [completeOnboarding, onComplete, onOpenGitHubImport]);
}, [completeOnboarding, isGitHubReady, onComplete, onOpenGitHubImport]);
// Dismiss without completing (still marks onboarding complete)
const handleDismiss = useCallback(async () => {
@@ -1288,6 +1298,7 @@ export function ModelOnboardingModal({
const aiProviders = authProviders.filter((provider) => provider.id !== "github");
const connectedAiProviders = aiProviders.filter((provider) => provider.authenticated);
const hasAiProvider = connectedAiProviders.length > 0;
const hasProjectSelected = Boolean(projectId);
// True when on GitHub step but skipped AI setup (no AI provider connected)
const aiSetupSkipped = step === "github" && !hasAiProvider;
@@ -1316,6 +1327,20 @@ export function ModelOnboardingModal({
const readinessItems: ReadinessItem[] = [];
if (hasProjectSelected) {
readinessItems.push({
label: "Project",
status: "connected",
detail: "Project selected — task creation and imports are available",
});
} else {
readinessItems.push({
label: "Project",
status: "missing",
detail: "Register a project to enable task creation and imports",
});
}
if (hasAiProvider) {
const firstConnectedProviderName = getProviderDisplayName(connectedAiProviders[0]?.id ?? "");
readinessItems.push({
@@ -1906,7 +1931,9 @@ export function ModelOnboardingModal({
{step === "first-task" && (
<div className="model-onboarding-first-task">
<p className="model-onboarding-description">
Your workspace is ready. Here's how to get started:
{hasProjectSelected
? "Your workspace is ready. Here's how to get started:"
: "Before creating your first task, register a project directory."}
</p>
{showTaskCreated && createdTaskForDisplay ? (
@@ -1939,97 +1966,119 @@ export function ModelOnboardingModal({
</div>
) : (
<>
<div className="onboarding-first-task-form">
<label className="onboarding-first-task-form__label" htmlFor="onboarding-first-task-input">
Describe your first task
</label>
<textarea
id="onboarding-first-task-input"
className="input onboarding-first-task-form__input"
data-testid="onboarding-first-task-input"
value={firstTaskDescription}
onChange={(event) => {
setFirstTaskDescription(event.target.value);
setTaskCreationError(null);
}}
placeholder="Example: Build a login page with email and password"
rows={4}
/>
<div className="onboarding-first-task-form__actions">
{!hasProjectSelected ? (
<div className="onboarding-project-prerequisite" data-testid="onboarding-project-prerequisite">
<p className="onboarding-helper-text">
A project must be selected before you can create tasks or import from GitHub.
</p>
<button
type="button"
className="btn btn-primary"
onClick={handleCreateFirstTask}
disabled={isCreatingFirstTask}
data-testid="onboarding-first-task-submit"
onClick={onOpenSetupWizard}
data-testid="onboarding-open-setup-wizard"
>
{isCreatingFirstTask ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>Creating task…</span>
</>
) : (
"Create First Task"
)}
Set Up Project
</button>
</div>
</div>
) : (
<>
<div className="onboarding-first-task-form">
<label className="onboarding-first-task-form__label" htmlFor="onboarding-first-task-input">
Describe your first task
</label>
<textarea
id="onboarding-first-task-input"
className="input onboarding-first-task-form__input"
data-testid="onboarding-first-task-input"
value={firstTaskDescription}
onChange={(event) => {
setFirstTaskDescription(event.target.value);
setTaskCreationError(null);
}}
placeholder="Example: Build a login page with email and password"
rows={4}
/>
<div className="onboarding-first-task-form__actions">
<button
type="button"
className="btn btn-primary"
onClick={handleCreateFirstTask}
disabled={isCreatingFirstTask}
data-testid="onboarding-first-task-submit"
>
{isCreatingFirstTask ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>Creating task…</span>
</>
) : (
"Create First Task"
)}
</button>
</div>
</div>
{taskCreationError && (
<div className="onboarding-task-error" role="alert" data-testid="onboarding-task-error">
<p className="field-error">{taskCreationError}</p>
<p className="onboarding-helper-text">Your text has been preserved — fix the issue and try again.</p>
</div>
{taskCreationError && (
<div className="onboarding-task-error" role="alert" data-testid="onboarding-task-error">
<p className="field-error">{taskCreationError}</p>
<p className="onboarding-helper-text">Your text has been preserved — fix the issue and try again.</p>
</div>
)}
</>
)}
<ReadinessSummary items={readinessItems} />
<OnboardingDisclosure summary="What happens when I create a task?">
<p className="onboarding-helper-text">
A task describes something you want done. Fusion's AI agents will read
your description and work on implementing it. You can track progress on
the board and review the results.
</p>
</OnboardingDisclosure>
{hasProjectSelected && (
<>
<OnboardingDisclosure summary="What happens when I create a task?">
<p className="onboarding-helper-text">
A task describes something you want done. Fusion's AI agents will read
your description and work on implementing it. You can track progress on
the board and review the results.
</p>
</OnboardingDisclosure>
<div className="onboarding-cta-options">
<button
className="onboarding-cta-card primary"
onClick={handleOpenNewTask}
disabled={saving}
>
<div className="cta-icon">
<Plus size={24} />
</div>
<div className="cta-content">
<strong>Create a New Task</strong>
<span>Describe what you need built and AI will work on it</span>
</div>
</button>
<div className="onboarding-cta-options">
<button
className="onboarding-cta-card primary"
onClick={handleOpenNewTask}
disabled={saving}
>
<div className="cta-icon">
<Plus size={24} />
</div>
<div className="cta-content">
<strong>Create a New Task</strong>
<span>Describe what you need built and AI will work on it</span>
</div>
</button>
<button
className={`onboarding-cta-card${!isGitHubReady ? " onboarding-cta-card--disabled" : ""}`}
data-testid="cta-github-import"
onClick={handleOpenGitHubImport}
disabled={saving}
>
<div className="cta-icon">
<GitPullRequest size={24} />
<button
className={`onboarding-cta-card${!isGitHubReady ? " onboarding-cta-card--disabled" : ""}`}
data-testid="cta-github-import"
onClick={handleOpenGitHubImport}
disabled={saving || !isGitHubReady}
>
<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>
{!isGitHubReady && (
<small className="onboarding-cta-note">Requires GitHub connection</small>
)}
</div>
</button>
</div>
<div className="cta-content">
<strong>Import from GitHub</strong>
<span>Turn GitHub issues into tasks you can track here</span>
{!isGitHubReady && (
<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>
<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>

View File

@@ -74,8 +74,12 @@ vi.mock("../SetupWizardModal", () => ({
SetupWizardModal: () => null,
}));
const mockModelOnboardingModalProps = vi.fn();
vi.mock("../ModelOnboardingModal", () => ({
ModelOnboardingModal: () => null,
ModelOnboardingModal: (props: any) => {
mockModelOnboardingModalProps(props);
return null;
},
}));
vi.mock("../ToastContainer", () => ({
@@ -184,6 +188,7 @@ describe("AppModals", () => {
beforeEach(() => {
vi.clearAllMocks();
mockScheduledTasksModalProps.mockClear();
mockModelOnboardingModalProps.mockClear();
});
it("renders without crashing", () => {
@@ -197,7 +202,7 @@ describe("AppModals", () => {
toasts={mockToasts}
removeToast={vi.fn()}
modalManager={mockModalManager}
projectActions={{ handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
projectActions={{ handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
deepLink={{ handleDetailClose: vi.fn() }}
@@ -207,6 +212,36 @@ describe("AppModals", () => {
expect(document.body).toBeDefined();
});
describe("ModelOnboardingModal wiring", () => {
it("passes project context and setup-wizard callback into onboarding modal", () => {
const handleAddProject = vi.fn();
const manager = { ...mockModalManager, modelOnboardingOpen: true };
render(
<AppModals
projectId={undefined}
tasks={[]}
projects={[]}
currentProject={null}
addToast={vi.fn()}
toasts={mockToasts}
removeToast={vi.fn()}
modalManager={manager}
projectActions={{ handleAddProject, handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
deepLink={{ handleDetailClose: vi.fn() }}
settings={mockSettings}
/>,
);
expect(mockModelOnboardingModalProps).toHaveBeenCalledTimes(1);
const props = mockModelOnboardingModalProps.mock.calls[0][0];
expect(props.projectId).toBe("");
expect(props.onOpenSetupWizard).toBe(handleAddProject);
});
});
describe("ScheduledTasksModal projectId forwarding", () => {
it("does not render ScheduledTasksModal when schedulesOpen is false", () => {
const manager = { ...mockModalManager, schedulesOpen: false };
@@ -220,7 +255,7 @@ describe("AppModals", () => {
toasts={mockToasts}
removeToast={vi.fn()}
modalManager={manager}
projectActions={{ handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
projectActions={{ handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
deepLink={{ handleDetailClose: vi.fn() }}
@@ -242,7 +277,7 @@ describe("AppModals", () => {
toasts={mockToasts}
removeToast={vi.fn()}
modalManager={manager}
projectActions={{ handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
projectActions={{ handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
deepLink={{ handleDetailClose: vi.fn() }}
@@ -266,7 +301,7 @@ describe("AppModals", () => {
toasts={mockToasts}
removeToast={vi.fn()}
modalManager={manager}
projectActions={{ handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
projectActions={{ handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
deepLink={{ handleDetailClose: vi.fn() }}
@@ -290,7 +325,7 @@ describe("AppModals", () => {
toasts={mockToasts}
removeToast={vi.fn()}
modalManager={manager}
projectActions={{ handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
projectActions={{ handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
deepLink={{ handleDetailClose: vi.fn() }}

View File

@@ -134,6 +134,7 @@ interface RenderModalOverrides {
onOpenGitHubImport?: () => void;
onViewTask?: (task: Task) => void;
firstCreatedTask?: Task | null;
projectId?: string;
}
interface RenderModalResult extends RenderResult {
@@ -155,6 +156,7 @@ function renderModal(overrides: RenderModalOverrides = {}): RenderModalResult {
<ModelOnboardingModal
onComplete={onComplete}
addToast={addToast}
projectId={overrides.projectId ?? "proj_123"}
onOpenNewTask={onOpenNewTask}
onOpenGitHubImport={onOpenGitHubImport}
onViewTask={onViewTask}
@@ -263,7 +265,7 @@ function AuthOnboardingHarness({ openModelOnboardingSpy, openSettingsSpy }: Auth
return (
<>
{modelOnboardingOpen ? (
<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />
<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />
) : (
<div data-testid="onboarding-modal-closed" />
)}
@@ -795,6 +797,7 @@ describe("onboarding flow integration", () => {
setIsOpen(false);
}}
addToast={vi.fn()}
projectId="proj_123"
/>
) : (
<div data-testid="modal-host-closed" />
@@ -866,6 +869,13 @@ describe("onboarding flow integration", () => {
});
it("task creation flow: Import from GitHub CTA completes onboarding and triggers import", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: true, type: "oauth" },
],
});
const renderResult = renderModal();
await advanceThroughSteps(renderResult, ["next", "next"]);

View File

@@ -25530,6 +25530,27 @@ html .column.drag-over * {
justify-content: flex-end;
}
/* === Onboarding Project Prerequisite === */
.onboarding-project-prerequisite {
display: flex;
flex-direction: column;
gap: var(--space-md);
padding: var(--space-lg);
border-radius: var(--radius-lg);
border: 1px solid color-mix(in srgb, var(--todo) 35%, var(--border));
background: color-mix(in srgb, var(--todo) 10%, var(--surface));
}
.onboarding-project-prerequisite .onboarding-helper-text {
margin: 0;
color: var(--text);
line-height: calc(var(--space-lg) + (var(--space-xs) / 2));
}
.onboarding-project-prerequisite .btn {
align-self: flex-start;
}
/* === Onboarding Task Error === */
.onboarding-task-error {
margin-top: var(--space-sm);
@@ -25623,6 +25644,23 @@ html .column.drag-over * {
opacity: 0.6;
}
.onboarding-cta-card--disabled,
.onboarding-cta-card:disabled {
cursor: not-allowed;
}
.onboarding-cta-card:disabled:hover {
border-color: var(--border);
background: var(--surface);
transform: none;
}
.onboarding-cta-card:disabled:focus,
.onboarding-cta-card:disabled:focus-visible {
outline: none;
box-shadow: none;
}
.onboarding-cta-note {
display: block;
font-size: calc(var(--space-md) - (var(--space-xs) / 4));
@@ -26105,6 +26143,15 @@ html .column.drag-over * {
display: none;
}
.onboarding-project-prerequisite {
padding: var(--space-md);
gap: var(--space-sm);
}
.onboarding-project-prerequisite .btn {
width: 100%;
}
.onboarding-cta-card {
padding: 12px;
}