feat(FN-1900): merge fusion/fn-1900
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user