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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7468-onboarding-quick-providers.md
Normal file
7
.changeset/fn-7468-onboarding-quick-providers.md
Normal file
@@ -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.
|
||||
@@ -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.")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<OnboardingDisclosure summary={t("setup.advancedProviderSettings", "Advanced provider settings")} className="onboarding-provider-advanced">
|
||||
<div data-testid="onboarding-advanced-provider-settings">
|
||||
{advancedProviders.length > 0 ? (
|
||||
<div className="model-onboarding-providers">
|
||||
{advancedProviders.map((provider) => renderAiProviderCard(provider))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="onboarding-helper-text">
|
||||
{t("setup.allProvidersShown", "All currently available providers are already shown above.")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{customProviders.length > 0 ? (
|
||||
<div className="onboarding-custom-provider-list">
|
||||
{customProviders.map((provider) => (
|
||||
<div key={provider.id} className="onboarding-custom-provider-item">
|
||||
<ProviderIcon provider={provider.id} size="sm" />
|
||||
<span>{provider.name || provider.id}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!showCustomProviderForm ? (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setShowCustomProviderForm(true)}>
|
||||
{t("setup.addCustomProvider", "Add custom provider")}
|
||||
</button>
|
||||
) : (
|
||||
<CustomProviderForm
|
||||
onSave={handleSaveCustomProvider}
|
||||
onCancel={() => { setShowCustomProviderForm(false); setCustomProviderError(undefined); }}
|
||||
saving={customProviderSaving}
|
||||
error={customProviderError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</OnboardingDisclosure>
|
||||
</section>
|
||||
|
||||
{connectedNonQuickStartProviders.length > 0 && (
|
||||
@@ -2592,44 +2636,6 @@ export function ModelOnboardingModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<OnboardingDisclosure summary={t("setup.advancedProviderSettings", "Advanced provider settings")} className="onboarding-provider-advanced">
|
||||
<div data-testid="onboarding-advanced-provider-settings">
|
||||
{advancedProviders.length > 0 ? (
|
||||
<div className="model-onboarding-providers">
|
||||
{advancedProviders.map((provider) => renderAiProviderCard(provider))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="onboarding-helper-text">
|
||||
{t("setup.allProvidersShown", "All currently available providers are already shown above.")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{customProviders.length > 0 ? (
|
||||
<div className="onboarding-custom-provider-list">
|
||||
{customProviders.map((provider) => (
|
||||
<div key={provider.id} className="onboarding-custom-provider-item">
|
||||
<ProviderIcon provider={provider.id} size="sm" />
|
||||
<span>{provider.name || provider.id}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!showCustomProviderForm ? (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setShowCustomProviderForm(true)}>
|
||||
{t("setup.addCustomProvider", "Add custom provider")}
|
||||
</button>
|
||||
) : (
|
||||
<CustomProviderForm
|
||||
onSave={handleSaveCustomProvider}
|
||||
onCancel={() => { setShowCustomProviderForm(false); setCustomProviderError(undefined); }}
|
||||
saving={customProviderSaving}
|
||||
error={customProviderError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</OnboardingDisclosure>
|
||||
|
||||
{/* OAuth login disclosure */}
|
||||
{hasOauthProviders && (
|
||||
<OnboardingDisclosure summary={t("setup.howDoesLoginWork", "How does login work?")}>
|
||||
|
||||
@@ -225,6 +225,12 @@ async function navigateToFirstTaskStep() {
|
||||
});
|
||||
}
|
||||
|
||||
function getProviderOrderInSection(container: HTMLElement): string[] {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>("[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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
|
||||
|
||||
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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
|
||||
|
||||
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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
|
||||
|
||||
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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
|
||||
|
||||
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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
|
||||
|
||||
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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
|
||||
|
||||
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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user