From d16c8b429b4fe090eb78cc69b09430b881c6b8a5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 3 Jul 2026 15:39:24 -0700 Subject: [PATCH] FN-7468: expand onboarding quick-start providers Expand onboarding provider setup so practical first-run choices and advanced controls stay anchored to quick start. - Add OpenRouter to the curated quick-start provider set and ordering. - Move advanced/all-provider and custom-provider controls under the quick-start provider section. - Cover provider ordering, split Anthropic behavior, nested advanced controls, and empty states in onboarding tests. - Add a minor changeset for the published Fusion package. Files changed: .changeset/fn-7468-onboarding-quick-providers.md | 7 ++ .../app/components/ModelOnboardingModal.tsx | 88 ++++++++-------- .../__tests__/ModelOnboardingModal.test.tsx | 116 +++++++++++++++++++-- .../components/__tests__/onboarding-flow.test.tsx | 17 +-- 4 files changed, 173 insertions(+), 55 deletions(-) Fusion-Task-Id: FN-7468 Fusion-Task-Lineage: 744d8a9f-d03d-4909-a00a-00bbae4e59c7 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7468-onboarding-quick-providers.md | 7 ++ .../app/components/ModelOnboardingModal.tsx | 88 ++++++------- .../__tests__/ModelOnboardingModal.test.tsx | 116 +++++++++++++++++- .../__tests__/onboarding-flow.test.tsx | 17 +-- 4 files changed, 173 insertions(+), 55 deletions(-) create mode 100644 .changeset/fn-7468-onboarding-quick-providers.md diff --git a/.changeset/fn-7468-onboarding-quick-providers.md b/.changeset/fn-7468-onboarding-quick-providers.md new file mode 100644 index 0000000000..5fec19580c --- /dev/null +++ b/.changeset/fn-7468-onboarding-quick-providers.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Expand first-run AI provider quick-start choices beyond Anthropic. +category: feature +dev: Moves advanced/all-provider onboarding controls under the quick-start provider section. diff --git a/packages/dashboard/app/components/ModelOnboardingModal.tsx b/packages/dashboard/app/components/ModelOnboardingModal.tsx index 53756029d7..837b099adf 100644 --- a/packages/dashboard/app/components/ModelOnboardingModal.tsx +++ b/packages/dashboard/app/components/ModelOnboardingModal.tsx @@ -245,10 +245,11 @@ function getProviderDisplayName(providerId: string): string { } /* -FNXC:ProviderAuth 2026-06-29-23:58: -Onboarding quick start must show Anthropic subscription OAuth and raw Anthropic API-key auth as separate first-class cards; keep the legacy `anthropic` id as a fallback only for older status payloads. +FNXC:Onboarding 2026-07-03-00:00: +Onboarding quick start must show practical first-run choices beyond Anthropic, including OpenAI/ChatGPT, Google/Gemini, OpenRouter, and Ollama when those visible providers are configured. +Keep Anthropic subscription OAuth and raw Anthropic API-key auth as separate first-class cards; use the legacy `anthropic` id only as a fallback for older status payloads. */ -const QUICK_START_PROVIDER_IDS = ["anthropic-subscription", "anthropic-api-key", "anthropic", "openai", "google", "gemini", "ollama"] as const; +const QUICK_START_PROVIDER_IDS = ["anthropic-subscription", "anthropic-api-key", "anthropic", "openai", "google", "gemini", "openrouter", "ollama"] as const; const ONBOARDING_CURATED_PROVIDER_FAMILY_ORDER = [ "anthropic", @@ -257,6 +258,7 @@ const ONBOARDING_CURATED_PROVIDER_FAMILY_ORDER = [ "cursor-cli", "llama-cpp", "openai-codex", + "openrouter", "gemini", "minimax", "kimi", @@ -2539,6 +2541,48 @@ export function ModelOnboardingModal({ {t("setup.noQuickStartProviders", "No quick-start providers are available in this environment.")}

)} + + {/* + FNXC:Onboarding 2026-07-03-00:00: + Expanded/all-provider controls belong directly under quick-start providers so the advanced provider list, custom-provider list, and add-custom-provider form stay visually tied to the first provider decision. + */} + +
+ {advancedProviders.length > 0 ? ( +
+ {advancedProviders.map((provider) => renderAiProviderCard(provider))} +
+ ) : ( +

+ {t("setup.allProvidersShown", "All currently available providers are already shown above.")} +

+ )} + + {customProviders.length > 0 ? ( +
+ {customProviders.map((provider) => ( +
+ + {provider.name || provider.id} +
+ ))} +
+ ) : null} + + {!showCustomProviderForm ? ( + + ) : ( + { setShowCustomProviderForm(false); setCustomProviderError(undefined); }} + saving={customProviderSaving} + error={customProviderError} + /> + )} +
+
{connectedNonQuickStartProviders.length > 0 && ( @@ -2592,44 +2636,6 @@ export function ModelOnboardingModal({ )} - -
- {advancedProviders.length > 0 ? ( -
- {advancedProviders.map((provider) => renderAiProviderCard(provider))} -
- ) : ( -

- {t("setup.allProvidersShown", "All currently available providers are already shown above.")} -

- )} - - {customProviders.length > 0 ? ( -
- {customProviders.map((provider) => ( -
- - {provider.name || provider.id} -
- ))} -
- ) : null} - - {!showCustomProviderForm ? ( - - ) : ( - { setShowCustomProviderForm(false); setCustomProviderError(undefined); }} - saving={customProviderSaving} - error={customProviderError} - /> - )} -
-
- {/* OAuth login disclosure */} {hasOauthProviders && ( diff --git a/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx b/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx index 734c2fffa4..3a53534d93 100644 --- a/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx @@ -225,6 +225,12 @@ async function navigateToFirstTaskStep() { }); } +function getProviderOrderInSection(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll("[data-testid^='onboarding-provider-card-']")) + .map((card) => card.dataset.testid?.replace("onboarding-provider-card-", "") ?? "") + .filter(Boolean); +} + beforeEach(() => { vi.clearAllMocks(); clearAuthToken(); @@ -603,6 +609,43 @@ describe("ModelOnboardingModal", () => { expect(screen.getByRole("button", { name: /Advanced provider settings/ })).toBeTruthy(); }); + it("orders practical quick-start providers without showing filtered providers", async () => { + mockFetchAuthStatus.mockResolvedValueOnce({ + providers: [ + { id: "minimax", name: "MiniMax", authenticated: false, type: "api_key" }, + { id: "ollama", name: "Ollama", authenticated: false, type: "api_key" }, + { id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" }, + { id: "google-antigravity", name: "Google Antigravity", authenticated: false, type: "oauth" }, + { id: "google", name: "Google", authenticated: false, type: "api_key" }, + { id: "openai", name: "OpenAI", authenticated: false, type: "api_key" }, + { id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" }, + ], + }); + + render(); + + const quickStartSection = await screen.findByTestId("onboarding-quick-start-providers"); + expect(getProviderOrderInSection(quickStartSection)).toEqual(["anthropic", "openai", "google", "openrouter", "ollama"]); + expect(screen.queryByTestId("onboarding-provider-card-google-antigravity")).toBeNull(); + }); + + it("preserves split Anthropic quick-start cards and suppresses legacy Anthropic fallback", async () => { + mockFetchAuthStatus.mockResolvedValueOnce({ + providers: [ + { id: "openai", name: "OpenAI", authenticated: false, type: "api_key" }, + { id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" }, + { id: "anthropic-api-key", name: "Anthropic API Key", authenticated: false, type: "api_key" }, + { id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth" }, + ], + }); + + render(); + + const quickStartSection = await screen.findByTestId("onboarding-quick-start-providers"); + expect(getProviderOrderInSection(quickStartSection)).toEqual(["anthropic-subscription", "anthropic-api-key", "openai"]); + expect(within(quickStartSection).queryByTestId("onboarding-provider-card-anthropic")).toBeNull(); + }); + it("keeps advanced providers hidden by default until advanced settings is expanded", async () => { mockFetchAuthStatus.mockResolvedValueOnce({ providers: [ @@ -629,7 +672,7 @@ describe("ModelOnboardingModal", () => { mockFetchAuthStatus.mockResolvedValueOnce({ providers: [ { id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" }, - { id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" }, + { id: "minimax", name: "MiniMax", authenticated: true, type: "api_key" }, ], }); @@ -640,8 +683,8 @@ describe("ModelOnboardingModal", () => { }); const connectedSection = screen.getByTestId("onboarding-connected-providers"); - expect(within(connectedSection).getByText("OpenRouter")).toBeTruthy(); - expect(screen.queryByTestId("onboarding-provider-card-openrouter")).toBeTruthy(); + expect(within(connectedSection).getByText("MiniMax")).toBeTruthy(); + expect(screen.queryByTestId("onboarding-provider-card-minimax")).toBeTruthy(); expect(screen.queryByTestId("onboarding-advanced-provider-settings")).toBeNull(); }); @@ -649,14 +692,75 @@ describe("ModelOnboardingModal", () => { mockFetchAuthStatus.mockResolvedValueOnce({ providers: [ { id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" }, - { id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" }, + { id: "minimax", name: "MiniMax", authenticated: true, type: "api_key" }, ], }); render(); - const openRouterWrapper = await screen.findByTestId("onboarding-provider-icon-openrouter"); - expect(within(openRouterWrapper).getByTestId("provider-icon")).toHaveAttribute("data-provider", "openrouter"); + const minimaxWrapper = await screen.findByTestId("onboarding-provider-icon-minimax"); + expect(within(minimaxWrapper).getByTestId("provider-icon")).toHaveAttribute("data-provider", "minimax"); + }); + + it("places the single advanced-provider disclosure inside quick start before default model selection", async () => { + render(); + + const quickStartSection = await screen.findByTestId("onboarding-quick-start-providers"); + const advancedButtons = screen.getAllByRole("button", { name: /Advanced provider settings/ }); + expect(advancedButtons).toHaveLength(1); + expect(quickStartSection).toContainElement(advancedButtons[0]); + + const defaultModelHeading = screen.getByText("Default Model (Optional)"); + expect( + quickStartSection.compareDocumentPosition(defaultModelHeading) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it("shows empty advanced state when every visible provider is already quick-start", async () => { + mockFetchAuthStatus.mockResolvedValueOnce({ + providers: [ + { id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" }, + { id: "openai", name: "OpenAI", authenticated: false, type: "api_key" }, + ], + }); + + render(); + + expect(await screen.findByTestId("onboarding-quick-start-providers")).toBeTruthy(); + expect(screen.queryByTestId("onboarding-advanced-provider-settings")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: /Advanced provider settings/ })); + + expect(await screen.findByTestId("onboarding-advanced-provider-settings")).toHaveTextContent( + "All currently available providers are already shown above.", + ); + }); + + it("keeps existing custom providers and add-custom controls in the quick-start advanced area", async () => { + mockFetchCustomProviders.mockResolvedValueOnce({ + providers: [ + { id: "custom-openai", name: "Custom OpenAI", baseUrl: "https://example.com", api: "openai-responses", models: [] }, + ], + }); + + render(); + + const quickStartSection = await screen.findByTestId("onboarding-quick-start-providers"); + fireEvent.click(within(quickStartSection).getByRole("button", { name: /Advanced provider settings/ })); + + const advancedPanel = await screen.findByTestId("onboarding-advanced-provider-settings"); + expect(within(advancedPanel).getByText("Custom OpenAI")).toBeTruthy(); + expect(within(advancedPanel).getByRole("button", { name: /Add custom provider/ })).toBeTruthy(); + }); + + it("shows the no-provider empty state without rendering leftover advanced-provider controls", async () => { + mockFetchAuthStatus.mockResolvedValueOnce({ providers: [] }); + + render(); + + expect(await screen.findByText("No AI providers are configured. Please check your Fusion configuration.")).toBeTruthy(); + expect(screen.queryByTestId("onboarding-quick-start-providers")).toBeNull(); + expect(screen.queryByRole("button", { name: /Advanced provider settings/ })).toBeNull(); }); it("shows model dropdown in AI Setup step", async () => { diff --git a/packages/dashboard/app/components/__tests__/onboarding-flow.test.tsx b/packages/dashboard/app/components/__tests__/onboarding-flow.test.tsx index 0465444098..80ea8348d5 100644 --- a/packages/dashboard/app/components/__tests__/onboarding-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/onboarding-flow.test.tsx @@ -433,7 +433,8 @@ describe("onboarding flow integration", () => { { id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" }, { id: "google", name: "Google", authenticated: false, type: "oauth" }, { id: "minimax", name: "MiniMax", authenticated: false, type: "api_key" }, - { id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" }, + { id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" }, + { id: "zai", name: "Zhipu AI", authenticated: true, type: "api_key" }, { id: "github", name: "GitHub", authenticated: true, type: "oauth" }, ], }); @@ -448,10 +449,10 @@ describe("onboarding flow integration", () => { expect(screen.getByRole("button", { name: /Advanced provider settings/ })).toHaveAttribute("aria-expanded", "false"); const quickStartSection = screen.getByTestId("onboarding-quick-start-providers"); - expect(getProviderOrderInSection(quickStartSection)).toEqual(["anthropic", "openai", "google"]); + expect(getProviderOrderInSection(quickStartSection)).toEqual(["anthropic", "openai", "google", "openrouter"]); const connectedSection = screen.getByTestId("onboarding-connected-providers"); - expect(getProviderOrderInSection(connectedSection)).toEqual(["openrouter"]); + expect(getProviderOrderInSection(connectedSection)).toEqual(["zai"]); expect(screen.queryByTestId("onboarding-provider-card-minimax")).not.toBeInTheDocument(); expect(screen.queryByTestId("onboarding-provider-card-moonshot")).not.toBeInTheDocument(); @@ -474,7 +475,7 @@ describe("onboarding flow integration", () => { mockFetchAuthStatus.mockResolvedValue({ providers: [ { id: "openai", name: "OpenAI", authenticated: false, type: "api_key" }, - { id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" }, + { id: "minimax", name: "MiniMax", authenticated: true, type: "api_key" }, { id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth" }, { id: "claude-cli", name: "Anthropic — via Claude CLI", authenticated: false, type: "cli" }, ], @@ -489,7 +490,7 @@ describe("onboarding flow integration", () => { expect(screen.getByTestId("onboarding-apikey-save-openai")).toBeInTheDocument(); const connectedSection = screen.getByTestId("onboarding-connected-providers"); - expect(within(connectedSection).getByText("OpenRouter")).toBeInTheDocument(); + expect(within(connectedSection).getByText("MiniMax")).toBeInTheDocument(); expect(within(connectedSection).getByRole("button", { name: "Remove Key" })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /Advanced provider settings/ })); @@ -608,7 +609,7 @@ describe("onboarding flow integration", () => { expect(hasSavedStateCall("github")).toBe(true); }); - expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ modelOnboardingComplete: true }); + expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(expect.objectContaining({ modelOnboardingComplete: true })); expect(renderResult.onComplete).toHaveBeenCalledTimes(1); expect(mockMarkOnboardingCompleted).not.toHaveBeenCalled(); }); @@ -977,7 +978,7 @@ describe("onboarding flow integration", () => { }); expect(mockMarkOnboardingCompleted).toHaveBeenCalled(); - expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ modelOnboardingComplete: true }); + expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(expect.objectContaining({ modelOnboardingComplete: true })); await waitFor(() => { expect(screen.getByText("Your first task is ready!")).toBeInTheDocument(); }); @@ -1090,7 +1091,7 @@ describe("onboarding flow integration", () => { }); expect(renderResult.onOpenNewTask).toHaveBeenCalled(); - expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ modelOnboardingComplete: true }); + expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(expect.objectContaining({ modelOnboardingComplete: true })); }); it("task creation flow: Import from GitHub CTA completes onboarding and triggers import", async () => {