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

This commit is contained in:
gsxdsm
2026-04-16 09:53:15 -07:00
parent ca3da5c5a0
commit 4ea89605c6
3 changed files with 209 additions and 0 deletions

View File

@@ -690,6 +690,14 @@ export function ModelOnboardingModal({
const aiOauthProviders = oauthProviders.filter((p) => p.id !== "github");
const aiApiKeyProviders = apiKeyProviders.filter((p) => p.id !== "github");
// Skip-state detection: derived state for informational banners
// Detects whether at least one AI provider is connected (excludes GitHub)
const hasAiProvider = authProviders.some((p) => p.id !== "github" && p.authenticated);
// True when on GitHub step but skipped AI setup (no AI provider connected)
const aiSetupSkipped = step === "github" && !hasAiProvider;
// True when on First Task step but skipped GitHub
const githubSkipped = step === "first-task" && !isGithubAuthenticated;
return (
<div
className="modal-overlay open"
@@ -1078,6 +1086,17 @@ export function ModelOnboardingModal({
Fusion works without it.
</p>
{/* Skip-state banner: shown when AI setup was skipped */}
{aiSetupSkipped && (
<div className="onboarding-skip-banner" role="status">
<strong>No AI provider connected</strong>
<p>
AI features like task planning and code generation won&apos;t be available until you connect one.
You can set this up later in Settings.
</p>
</div>
)}
<OnboardingDisclosure summary="What does GitHub integration do?">
<p className="onboarding-helper-text">
Connecting GitHub lets you import issues as tasks, track pull requests,
@@ -1178,6 +1197,28 @@ export function ModelOnboardingModal({
Your workspace is ready. Here's how to get started:
</p>
{/* Skip-state banner: shown when GitHub was skipped */}
{githubSkipped && (
<div className="onboarding-skip-banner" role="status">
<strong>GitHub not connected</strong>
<p>
You won&apos;t be able to import issues from GitHub, but you can still create tasks manually.
Connect GitHub later from Settings.
</p>
</div>
)}
{/* Skip-state banner: shown when AI setup was also skipped */}
{!hasAiProvider && (
<div className="onboarding-skip-banner" role="status">
<strong>No AI provider connected</strong>
<p>
AI agents won&apos;t be able to work on tasks until you connect a provider.
Set one up later in Settings → AI Setup.
</p>
</div>
)}
<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

View File

@@ -2104,4 +2104,147 @@ describe("ModelOnboardingModal progressive disclosure", () => {
expect(screen.getByTestId("onboarding-auth-status-github")).toHaveClass("auth-status-badge");
});
});
describe("Skip-state messaging", () => {
it("shows AI provider skip banner on GitHub step when no provider connected", async () => {
// Start at ai-setup with no AI providers connected
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
// Should show skip banner about AI provider
const banners = screen.getAllByRole("status");
expect(banners.some(b => b.classList.contains("onboarding-skip-banner"))).toBe(true);
const skipBanner = screen.getByText("No AI provider connected").closest(".onboarding-skip-banner");
expect(skipBanner).toBeTruthy();
expect(skipBanner).toHaveTextContent(/AI features like task planning and code generation won't be available/);
});
it("does not show AI provider skip banner on GitHub step when provider is connected", async () => {
// Start with an AI provider already connected
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
// Should NOT show skip banner about AI provider
expect(screen.queryByText("No AI provider connected")).toBeNull();
});
it("shows GitHub skip banner on First Task step when GitHub not connected", async () => {
// AI provider connected but GitHub not connected
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
// Should show skip banner about GitHub
const skipBanner = screen.getByText("GitHub not connected").closest(".onboarding-skip-banner");
expect(skipBanner).toBeTruthy();
expect(skipBanner).toHaveTextContent(/won't be able to import issues from GitHub/);
});
it("does not show GitHub skip banner on First Task step when GitHub is connected", async () => {
// Both AI and GitHub connected - mock for all auth status calls
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: true, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
// Should NOT show skip banner about GitHub (use querySelector to avoid matching the auth badge)
const skipBanners = document.querySelectorAll(".onboarding-skip-banner");
const githubSkipBanner = Array.from(skipBanners).find(
(banner) => banner.textContent?.includes("GitHub not connected")
);
expect(githubSkipBanner).toBeUndefined();
});
it("shows both skip banners on First Task step when both AI and GitHub are skipped", async () => {
// Neither AI nor GitHub connected
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToFirstTaskStep();
// Should show BOTH skip banners
expect(screen.getByText("GitHub not connected")).toBeTruthy();
expect(screen.getByText("No AI provider connected")).toBeTruthy();
// Both should have the skip-banner class
const githubBanner = screen.getByText("GitHub not connected").closest(".onboarding-skip-banner");
const aiBanner = screen.getByText("No AI provider connected").closest(".onboarding-skip-banner");
expect(githubBanner).toBeTruthy();
expect(aiBanner).toBeTruthy();
});
it("does not show any skip banner on AI Setup step", async () => {
// No providers connected
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
// Should NOT show any skip banners on AI Setup step
expect(screen.queryByText("onboarding-skip-banner")).toBeNull();
const skipBanners = document.querySelectorAll(".onboarding-skip-banner");
expect(skipBanners.length).toBe(0);
});
it("skip banners have role=status for accessibility", async () => {
// Set up with no AI provider connected
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
// Check that skip banner has role="status"
const skipBanner = screen.getByText("No AI provider connected").closest(".onboarding-skip-banner");
expect(skipBanner).toHaveAttribute("role", "status");
});
});
});

View File

@@ -23536,6 +23536,31 @@ html .column.drag-over * {
line-height: 1.4;
}
/* === Skip-state informational banners === */
.onboarding-skip-banner {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-md);
border-radius: var(--radius-md);
border-left: 3px solid var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 8%, transparent);
}
.onboarding-skip-banner strong {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.onboarding-skip-banner p {
font-size: 12px;
color: var(--text-muted);
margin: 0;
line-height: 1.4;
}
/* === OnboardingDisclosure === */
.onboarding-disclosure {
display: flex;