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:
gsxdsm
2026-07-03 15:39:24 -07:00
parent 9dc248eae2
commit d16c8b429b
4 changed files with 173 additions and 55 deletions

View 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.

View File

@@ -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?")}>

View File

@@ -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 () => {

View File

@@ -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 () => {