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

This commit is contained in:
gsxdsm
2026-04-16 07:23:12 -07:00
parent de8c92b42a
commit 3c3584d46c
3 changed files with 308 additions and 3 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { X, Loader2, CheckCircle, Key, Zap, GitPullRequest, Rocket, Plus } from "lucide-react";
import { X, Loader2, CheckCircle, Key, Zap, GitPullRequest, Rocket, Plus, ChevronRight } from "lucide-react";
import type { AuthProvider, ModelInfo } from "../api";
import {
fetchAuthStatus,
@@ -31,6 +31,45 @@ const PROVIDER_INFO: Record<string, { description: string }> = {
/** Fallback description for providers not in the map */
const PROVIDER_INFO_FALLBACK = { description: "AI provider — connect to start using AI models" };
/** Props for OnboardingDisclosure component */
interface OnboardingDisclosureProps {
summary: string;
children: React.ReactNode;
className?: string;
}
/**
* Progressive disclosure component that reveals additional content on click.
* Used to hide technical details behind expandable "Learn more" sections.
*/
function OnboardingDisclosure({ summary, children, className = "" }: OnboardingDisclosureProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className={`onboarding-disclosure ${className}`}>
<button
className="onboarding-disclosure-trigger"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
type="button"
>
<ChevronRight
size={14}
className="onboarding-disclosure-chevron"
aria-hidden="true"
/>
<span>{summary}</span>
</button>
{isOpen && (
<div className="onboarding-disclosure-content">
{children}
</div>
)}
</div>
);
}
import {
getOnboardingState,
saveOnboardingState,
@@ -664,6 +703,15 @@ export function ModelOnboardingModal({
service or enter an API key.
</p>
{/* Provider explanation disclosure */}
<OnboardingDisclosure summary="What are AI providers?">
<p className="onboarding-helper-text">
AI providers like OpenAI and Anthropic power the AI capabilities in Fusion.
Connecting a provider lets Fusion's agents use AI models to help with your tasks.
You only need one provider to get started.
</p>
</OnboardingDisclosure>
{/* Show helper text when providers exist but none are authenticated */}
{authProviders.length > 0 && !authProviders.some((p) => p.authenticated) && (
<p className="onboarding-helper-text">
@@ -684,7 +732,9 @@ export function ModelOnboardingModal({
) : (
<>
{/* OAuth Providers */}
{aiOauthProviders.map((provider) => (
{aiOauthProviders.length > 0 && (
<>
{aiOauthProviders.map((provider) => (
<div
key={provider.id}
className={`onboarding-provider-card${provider.authenticated ? " onboarding-provider-card--connected" : ""}`}
@@ -752,7 +802,20 @@ export function ModelOnboardingModal({
</div>
))}
{/* API Key Providers */}
{/* OAuth login disclosure */}
<OnboardingDisclosure summary="How does login work?">
<p className="onboarding-helper-text">
Clicking Login opens the provider's website in a new tab where you sign in.
Once you authorize Fusion, this page will automatically detect the connection.
Your credentials are never stored in Fusion.
</p>
</OnboardingDisclosure>
</>
)}
{/* API Key Providers */}
{aiApiKeyProviders.length > 0 && (
<>
{aiApiKeyProviders.map((provider) => (
<div
key={provider.id}
@@ -832,8 +895,19 @@ export function ModelOnboardingModal({
</div>
</div>
))}
{/* API key disclosure */}
<OnboardingDisclosure summary="What is an API key?">
<p className="onboarding-helper-text">
An API key is a secret token that authenticates Fusion with the provider.
You can find your key in the provider's dashboard under API settings.
Keys are stored securely on your machine.
</p>
</OnboardingDisclosure>
</>
)}
</>
)}
{/* Model Selection */}
<div className="onboarding-model-section">
@@ -845,6 +919,14 @@ export function ModelOnboardingModal({
later. Models vary in speed, capability, and cost.
</p>
<OnboardingDisclosure summary="How do I choose a model?">
<p className="onboarding-helper-text">
Models vary in speed, capability, and cost. A good default is usually
the latest model from your connected provider. You can always change this
later in Settings.
</p>
</OnboardingDisclosure>
{availableModels.length === 0 ? (
<div className="model-onboarding-empty">
No models available yet. Connect a provider above to see model options.
@@ -882,6 +964,14 @@ export function ModelOnboardingModal({
Fusion works without it.
</p>
<OnboardingDisclosure summary="What does GitHub integration do?">
<p className="onboarding-helper-text">
Connecting GitHub lets you import issues as tasks, track pull requests,
and link code changes to your work. This is optional — you can create
tasks manually without it.
</p>
</OnboardingDisclosure>
{!hasGithubProvider ? (
<div className="model-onboarding-github-optional">
<GitPullRequest size={48} className="optional-icon" />
@@ -974,6 +1064,14 @@ export function ModelOnboardingModal({
Your workspace is ready. Here's how to get started:
</p>
<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"

View File

@@ -63,6 +63,23 @@ vi.mock("../ProviderIcon", () => ({
),
}));
// Mock lucide-react icons - preserve actual icons for other components
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>;
return {
...actual,
X: () => <span data-testid="icon-x">X</span>,
Loader2: ({ className }: { className?: string }) => <span data-testid="icon-loader" className={className}>Loader2</span>,
CheckCircle: () => <span data-testid="icon-check-circle">CheckCircle</span>,
Key: () => <span data-testid="icon-key">Key</span>,
Zap: () => <span data-testid="icon-zap">Zap</span>,
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>,
ChevronRight: () => <span data-testid="icon-chevron-right">ChevronRight</span>,
};
});
const defaultAuthProviders: AuthProvider[] = [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
@@ -1634,3 +1651,133 @@ describe("ModelOnboardingModal", () => {
});
});
});
describe("ModelOnboardingModal progressive disclosure", () => {
describe("AI Setup step disclosures", () => {
it("renders all 4 disclosure trigger buttons in AI Setup step", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
// Verify all 4 disclosures are present
expect(screen.getByText("What are AI providers?")).toBeTruthy();
expect(screen.getByText("How does login work?")).toBeTruthy();
expect(screen.getByText("What is an API key?")).toBeTruthy();
expect(screen.getByText("How do I choose a model?")).toBeTruthy();
});
it("clicking disclosure trigger expands content", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("What are AI providers?")).toBeTruthy();
});
const trigger = screen.getByRole("button", { name: /What are AI providers\?/ });
expect(trigger.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(trigger);
await waitFor(() => {
expect(trigger.getAttribute("aria-expanded")).toBe("true");
expect(screen.getByText(/AI providers like OpenAI and Anthropic/)).toBeTruthy();
});
});
it("clicking disclosure trigger again collapses content", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("What are AI providers?")).toBeTruthy();
});
const trigger = screen.getByRole("button", { name: /What are AI providers\?/ });
// Open
fireEvent.click(trigger);
await waitFor(() => {
expect(trigger.getAttribute("aria-expanded")).toBe("true");
});
// Close
fireEvent.click(trigger);
await waitFor(() => {
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(screen.queryByText(/AI providers like OpenAI and Anthropic/)).toBeNull();
});
});
it("multiple disclosures are independent", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("What are AI providers?")).toBeTruthy();
expect(screen.getByText("What is an API key?")).toBeTruthy();
});
const trigger1 = screen.getByRole("button", { name: /What are AI providers\?/ });
const trigger2 = screen.getByRole("button", { name: /What is an API key\?/ });
// Open first disclosure
fireEvent.click(trigger1);
await waitFor(() => {
expect(trigger1.getAttribute("aria-expanded")).toBe("true");
expect(trigger2.getAttribute("aria-expanded")).toBe("false");
expect(screen.getByText(/AI providers like OpenAI and Anthropic/)).toBeTruthy();
});
// Open second disclosure
fireEvent.click(trigger2);
await waitFor(() => {
expect(trigger1.getAttribute("aria-expanded")).toBe("true");
expect(trigger2.getAttribute("aria-expanded")).toBe("true");
expect(screen.getByText(/An API key is a secret token/)).toBeTruthy();
});
});
});
describe("GitHub step disclosures", () => {
it("renders GitHub integration disclosure in GitHub step", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
expect(screen.getByText("What does GitHub integration do?")).toBeTruthy();
});
});
describe("First Task step disclosures", () => {
it("renders task creation disclosure in First Task step", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
expect(screen.getByText("What happens when I create a task?")).toBeTruthy();
});
});
describe("Complete step disclosures", () => {
it("does not render any disclosure triggers in complete step", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
// Click Finish Setup
fireEvent.click(screen.getByText("Finish Setup"));
await waitFor(() => {
expect(screen.getByText("All Set!")).toBeTruthy();
});
// Verify no disclosure triggers exist
expect(screen.queryByText("What are AI providers?")).toBeNull();
expect(screen.queryByText("How does login work?")).toBeNull();
expect(screen.queryByText("What is an API key?")).toBeNull();
expect(screen.queryByText("How do I choose a model?")).toBeNull();
expect(screen.queryByText("What does GitHub integration do?")).toBeNull();
expect(screen.queryByText("What happens when I create a task?")).toBeNull();
});
});
});

View File

@@ -23519,6 +23519,66 @@ html .column.drag-over * {
line-height: 1.4;
}
/* === OnboardingDisclosure === */
.onboarding-disclosure {
display: flex;
flex-direction: column;
margin-top: var(--space-sm);
}
.onboarding-disclosure-trigger {
display: flex;
align-items: center;
gap: var(--space-xs);
background: none;
border: none;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
padding: var(--space-xs) 0;
transition: color var(--transition-fast);
font-family: inherit;
}
.onboarding-disclosure-trigger:hover {
color: var(--text);
}
.onboarding-disclosure-trigger:focus-visible {
outline: var(--focus-ring-strong);
border-radius: var(--radius-sm);
}
.onboarding-disclosure-chevron {
width: 14px;
height: 14px;
transition: transform var(--transition-fast);
flex-shrink: 0;
}
.onboarding-disclosure-trigger[aria-expanded="true"] .onboarding-disclosure-chevron {
transform: rotate(90deg);
}
.onboarding-disclosure-content {
padding: var(--space-sm) 0 var(--space-sm) 22px;
color: var(--text-muted);
font-size: 12px;
line-height: 1.5;
animation: onboarding-disclosure-enter 150ms ease-out;
}
@keyframes onboarding-disclosure-enter {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.model-onboarding-loading {
display: flex;
align-items: center;