feat(FN-2451): enforce connected-first AI onboarding provider order

- Rework ModelOnboardingModal provider rendering to use a single curated ordering across OAuth, API key, and Claude CLI cards while excluding GitHub
- Prioritize already-connected providers first, then apply deterministic family/alias sorting so Claude CLI appears near the top and provider controls remain intact
- Add onboarding-flow regression tests that verify provider ordering and preserved per-provider interactions after reordering
- Update dashboard styling to use token-based onboarding error text class and adjust related tests for resilient async and accessible section targeting
This commit is contained in:
Fusion
2026-04-24 12:35:48 -07:00
committed by gsxdsm
parent 366c0a69ac
commit 1db45d5802
6 changed files with 325 additions and 154 deletions

View File

@@ -308,7 +308,7 @@ function ClaudeCliActionToast({
}) {
if (action.kind === "error") {
return (
<p className="onboarding-helper-text" style={{ color: "var(--danger)" }}>
<p className="onboarding-helper-text onboarding-helper-text--error">
{action.message}
</p>
);

View File

@@ -163,6 +163,82 @@ function getProviderDisplayName(providerId: string): string {
.join(" ");
}
const ONBOARDING_CURATED_PROVIDER_FAMILY_ORDER = [
"anthropic",
"claude-cli",
"openai-codex",
"gemini",
"minimax",
"kimi",
"zai",
] as const;
const ONBOARDING_PROVIDER_FAMILY_ALIASES: Record<string, (typeof ONBOARDING_CURATED_PROVIDER_FAMILY_ORDER)[number]> = {
anthropic: "anthropic",
"claude-cli": "claude-cli",
"openai-codex": "openai-codex",
google: "gemini",
gemini: "gemini",
minimax: "minimax",
kimi: "kimi",
moonshot: "kimi",
"kimi-coding": "kimi",
zai: "zai",
};
const ONBOARDING_PROVIDER_ALIAS_ORDER: Record<string, string[]> = {
gemini: ["google", "gemini"],
kimi: ["kimi", "moonshot", "kimi-coding"],
};
function getOnboardingProviderFamilyId(providerId: string): string {
return ONBOARDING_PROVIDER_FAMILY_ALIASES[providerId] ?? providerId;
}
function getOnboardingProviderCuratedRank(providerId: string): number {
const familyId = getOnboardingProviderFamilyId(providerId);
const rank = ONBOARDING_CURATED_PROVIDER_FAMILY_ORDER.indexOf(
familyId as (typeof ONBOARDING_CURATED_PROVIDER_FAMILY_ORDER)[number],
);
return rank === -1 ? Number.POSITIVE_INFINITY : rank;
}
function compareOnboardingProviders(a: AuthProvider, b: AuthProvider): number {
if (a.authenticated !== b.authenticated) {
return a.authenticated ? -1 : 1;
}
const curatedRankA = getOnboardingProviderCuratedRank(a.id);
const curatedRankB = getOnboardingProviderCuratedRank(b.id);
if (curatedRankA !== curatedRankB) {
return curatedRankA - curatedRankB;
}
const familyA = getOnboardingProviderFamilyId(a.id);
const familyB = getOnboardingProviderFamilyId(b.id);
if (familyA !== familyB) {
return familyA.localeCompare(familyB);
}
const aliasOrder = ONBOARDING_PROVIDER_ALIAS_ORDER[familyA];
if (aliasOrder) {
const aliasRankA = aliasOrder.indexOf(a.id);
const aliasRankB = aliasOrder.indexOf(b.id);
if (aliasRankA !== aliasRankB) {
return (aliasRankA === -1 ? Number.POSITIVE_INFINITY : aliasRankA)
- (aliasRankB === -1 ? Number.POSITIVE_INFINITY : aliasRankB);
}
}
const nameCompare = a.name.localeCompare(b.name);
if (nameCompare !== 0) {
return nameCompare;
}
return a.id.localeCompare(b.id);
}
function validateApiKeyFormat(providerId: string, key: string): string | null {
const trimmedKey = key.trim();
if (!trimmedKey) {
@@ -1314,19 +1390,12 @@ export function ModelOnboardingModal({
if (!isOpen) return null;
const oauthProviders = authProviders.filter(
(p) => !p.type || p.type === "oauth",
);
const apiKeyProviders = authProviders.filter((p) => p.type === "api_key");
const cliProviders = authProviders.filter((p) => p.type === "cli");
// Filter out GitHub from AI providers list
const aiOauthProviders = oauthProviders.filter((p) => p.id !== "github");
const aiApiKeyProviders = apiKeyProviders.filter((p) => p.id !== "github");
const githubStatus = getGitHubStatus();
const aiProviders = authProviders.filter((provider) => provider.id !== "github");
const orderedAiProviders = [...aiProviders].sort(compareOnboardingProviders);
const hasOauthProviders = orderedAiProviders.some((provider) => !provider.type || provider.type === "oauth");
const hasApiKeyProviders = orderedAiProviders.some((provider) => provider.type === "api_key");
const connectedAiProviders = aiProviders.filter((provider) => provider.authenticated);
const hasAiProvider = connectedAiProviders.length > 0;
const hasProjectSelected = Boolean(projectId);
@@ -1605,154 +1674,149 @@ export function ModelOnboardingModal({
</div>
) : (
<>
{/* OAuth Providers */}
{aiOauthProviders.length > 0 && (
<>
{aiOauthProviders.map((provider) => (
<div
key={provider.id}
className={`onboarding-provider-card${provider.authenticated ? " onboarding-provider-card--connected" : ""}`}
>
<div className="onboarding-provider-card__icon">
<ProviderIcon provider={provider.id} size="md" />
</div>
<div className="onboarding-provider-card__body">
<strong className="onboarding-provider-card__name">{provider.name}</strong>
<span className="onboarding-provider-card__description">
{getProviderInfo(provider.id).description}
</span>
<ProviderStatusBadge status={getProviderStatus(provider)} />
</div>
<div className="onboarding-provider-card__actions">
{authActionInProgress === provider.id ? (
<>
<button className="btn btn-sm" disabled>
{provider.authenticated
? "Logging out…"
: "Waiting for login…"}
</button>
<button
className="btn btn-sm"
onClick={() => handleCancelLogin(provider.id)}
>
Cancel
</button>
</>
) : provider.authenticated ? (
<button
className="btn btn-sm"
onClick={() => handleLogout(provider.id)}
>
Logout
</button>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleLogin(provider.id)}
>
Login
</button>
)}
</div>
{/* Show timeout message */}
{loginOutcomes[provider.id] === "timeout" && authActionInProgress !== provider.id && (
<p className="onboarding-helper-text" style={{ marginTop: 4 }}>
Login timed out. Please try again.
</p>
)}
{/* Show failure message */}
{loginOutcomes[provider.id] === "failed" && authActionInProgress !== provider.id && (
<p className="field-error" style={{ marginTop: 4 }}>
Login failed. Please try again.
</p>
)}
</div>
))}
{orderedAiProviders.map((provider) => {
if (provider.id === "claude-cli" && provider.type === "cli") {
return (
<ClaudeCliProviderCard
key={provider.id}
authenticated={provider.authenticated}
onToggled={() => {
// Refetch auth status so the parent provider list
// reflects the new authenticated state.
void loadAuthStatus();
}}
/>
);
}
{/* 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>
</>
)}
if (provider.type === "api_key") {
const providerInfo = getProviderInfo(provider.id);
const apiKeyInfo = getApiKeyInfo(provider);
return (
<div
key={provider.id}
data-testid={`onboarding-provider-card-${provider.id}`}
className={`onboarding-provider-card${provider.authenticated ? " onboarding-provider-card--connected" : ""}`}
>
<div className="onboarding-provider-card__icon">
<ProviderIcon provider={provider.id} size="md" />
</div>
<div className="onboarding-provider-card__body">
<strong className="onboarding-provider-card__name">
<Key size={14} className="onboarding-provider-key-icon" />
{provider.name}
</strong>
<span className="onboarding-provider-card__description">
{providerInfo.description}
</span>
<ProviderStatusBadge status={getProviderStatus(provider)} />
{provider.authenticated && provider.keyHint && (
<span className="auth-key-hint">Key: {provider.keyHint}</span>
)}
</div>
<div className="onboarding-provider-card__actions onboarding-provider-card__actions--api-key">
<ApiKeyEntryForm
provider={provider}
apiKeyInfo={apiKeyInfo}
inputValue={apiKeyInputs[provider.id] ?? ""}
isSaving={authActionInProgress === provider.id}
error={apiKeyErrors[provider.id]}
success={apiKeySuccess[provider.id]}
isConnected={provider.authenticated}
onInputChange={handleApiKeyInputChange}
onSave={handleSaveApiKey}
onClear={handleClearApiKey}
/>
</div>
</div>
);
}
{/* API Key Providers */}
{aiApiKeyProviders.length > 0 && (
<>
{aiApiKeyProviders.map((provider) => {
const providerInfo = getProviderInfo(provider.id);
const apiKeyInfo = getApiKeyInfo(provider);
return (
<div
key={provider.id}
data-testid={`onboarding-provider-card-${provider.id}`}
className={`onboarding-provider-card${provider.authenticated ? " onboarding-provider-card--connected" : ""}`}
>
<div className="onboarding-provider-card__icon">
<ProviderIcon provider={provider.id} size="md" />
</div>
<div className="onboarding-provider-card__body">
<strong className="onboarding-provider-card__name">
<Key size={14} className="onboarding-provider-key-icon" />
{provider.name}
</strong>
<strong className="onboarding-provider-card__name">{provider.name}</strong>
<span className="onboarding-provider-card__description">
{providerInfo.description}
{getProviderInfo(provider.id).description}
</span>
<ProviderStatusBadge status={getProviderStatus(provider)} />
{provider.authenticated && provider.keyHint && (
<span className="auth-key-hint">Key: {provider.keyHint}</span>
</div>
<div className="onboarding-provider-card__actions">
{authActionInProgress === provider.id ? (
<>
<button className="btn btn-sm" disabled>
{provider.authenticated
? "Logging out…"
: "Waiting for login…"}
</button>
<button
className="btn btn-sm"
onClick={() => handleCancelLogin(provider.id)}
>
Cancel
</button>
</>
) : provider.authenticated ? (
<button
className="btn btn-sm"
onClick={() => handleLogout(provider.id)}
>
Logout
</button>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleLogin(provider.id)}
>
Login
</button>
)}
</div>
<div className="onboarding-provider-card__actions onboarding-provider-card__actions--api-key">
<ApiKeyEntryForm
provider={provider}
apiKeyInfo={apiKeyInfo}
inputValue={apiKeyInputs[provider.id] ?? ""}
isSaving={authActionInProgress === provider.id}
error={apiKeyErrors[provider.id]}
success={apiKeySuccess[provider.id]}
isConnected={provider.authenticated}
onInputChange={handleApiKeyInputChange}
onSave={handleSaveApiKey}
onClear={handleClearApiKey}
/>
</div>
{loginOutcomes[provider.id] === "timeout" && authActionInProgress !== provider.id && (
<p className="onboarding-helper-text" style={{ marginTop: 4 }}>
Login timed out. Please try again.
</p>
)}
{loginOutcomes[provider.id] === "failed" && authActionInProgress !== provider.id && (
<p className="field-error" style={{ marginTop: 4 }}>
Login failed. Please try again.
</p>
)}
</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>
</>
)}
</>
)}
{/* OAuth login disclosure */}
{hasOauthProviders && (
<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>
)}
{/* Claude CLI — synthetic provider card. Rendered alongside
OAuth + API-key cards but with its own action set
(Enable/Disable + Test) since it's backed by a binary
probe rather than stored credentials. */}
{cliProviders.some((p) => p.id === "claude-cli") && (
<ClaudeCliProviderCard
authenticated={
cliProviders.find((p) => p.id === "claude-cli")?.authenticated ?? false
}
onToggled={() => {
// Refetch auth status so the parent provider list
// reflects the new authenticated state.
void loadAuthStatus();
}}
/>
{/* API key disclosure */}
{hasApiKeyProviders && (
<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 */}

View File

@@ -1364,9 +1364,11 @@ describe("QuickEntryBox", () => {
expect(props.onCreate).toHaveBeenCalled();
});
// After creation, focus is restored → autoExpand triggers isExpanded=true
// But disclosure should still be false (controls hidden)
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
// After creation, focus is restored asynchronously.
// autoExpand should set textarea expanded while disclosure remains hidden.
await waitFor(() => {
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
});
expect(controls?.hasAttribute("hidden")).toBe(true);
});

View File

@@ -1050,7 +1050,7 @@ describe("SettingsModal", () => {
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Click on "Project Models" (project-scoped models section)
fireEvent.click(screen.getByText("Project Models"));
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Select planning model
@@ -1080,7 +1080,7 @@ describe("SettingsModal", () => {
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Click on "Project Models" (project-scoped models section)
fireEvent.click(screen.getByText("Project Models"));
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Select execution model
@@ -1103,7 +1103,7 @@ describe("SettingsModal", () => {
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Click on "Project Models" (project-scoped models section)
fireEvent.click(screen.getByText("Project Models"));
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
const planningFallbackTrigger = screen.getByLabelText("Planning Fallback Model");
@@ -1138,7 +1138,7 @@ describe("SettingsModal", () => {
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Model presets are in Project Models section
fireEvent.click(screen.getByText("Project Models"));
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
await user.click(screen.getByText("Add Preset"));
await user.type(screen.getByLabelText("Name"), "Budget");
@@ -1165,7 +1165,7 @@ describe("SettingsModal", () => {
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Project Models"));
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
const presetList = container.querySelector(".settings-preset-list");
expect(presetList).toBeTruthy();
@@ -1195,7 +1195,7 @@ describe("SettingsModal", () => {
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Auto-select presets are in Project Models section
fireEvent.click(screen.getByText("Project Models"));
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
await user.click(screen.getByLabelText("Auto-select preset based on task size"));
fireEvent.change(screen.getByLabelText("Small tasks (S):"), { target: { value: "budget" } });
fireEvent.change(screen.getByLabelText("Medium tasks (M):"), { target: { value: "normal" } });
@@ -1318,7 +1318,7 @@ describe("SettingsModal", () => {
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Project Models section has planning and validator model dropdowns
fireEvent.click(screen.getByText("Project Models"));
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Both dropdowns should be present
@@ -1332,7 +1332,7 @@ describe("SettingsModal", () => {
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Project Models section has planning model dropdown
fireEvent.click(screen.getByText("Project Models"));
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Open planning model dropdown and select a model
@@ -1355,7 +1355,7 @@ describe("SettingsModal", () => {
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Project Models section has validator model dropdown
fireEvent.click(screen.getByText("Project Models"));
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Open validator model dropdown and select a model
@@ -1410,7 +1410,7 @@ describe("SettingsModal", () => {
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Navigate to Project Models section and change planning model
fireEvent.click(screen.getByText("Project Models"));
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
const planningTrigger = screen.getByLabelText("Planning Model");
@@ -1455,7 +1455,7 @@ describe("SettingsModal", () => {
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Navigate to Project Models section
fireEvent.click(screen.getByText("Project Models"));
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Verify the planning lane shows as overridden
@@ -1483,7 +1483,7 @@ describe("SettingsModal", () => {
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Navigate to Project Models section
fireEvent.click(screen.getByText("Project Models"));
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Verify lanes show as inherited (don't change anything)
@@ -3321,7 +3321,7 @@ describe("SettingsModal", () => {
expect(modelsIcon).toBeTruthy();
// Switch to Project Models → should show project scope banner
fireEvent.click(screen.getByText("Project Models"));
fireEvent.click(screen.getByRole("button", { name: /Project Models/ }));
const projectModelsBanner = container.querySelector(".settings-scope-project");
expect(projectModelsBanner).toBeTruthy();
expect(projectModelsBanner?.textContent).toContain("project");

View File

@@ -76,6 +76,14 @@ vi.mock("../ProviderIcon", () => ({
),
}));
vi.mock("../ClaudeCliProviderCard", () => ({
ClaudeCliProviderCard: ({ authenticated }: { authenticated: boolean }) => (
<div data-testid="claude-cli-provider-card" data-authenticated={authenticated ? "true" : "false"}>
Anthropic via Claude CLI
</div>
),
}));
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>;
return {
@@ -239,6 +247,26 @@ function hasSavedStateCall(
});
}
function getAiSetupProviderOrder(): string[] {
const aiSetup = document.querySelector(".model-onboarding-ai-setup");
if (!aiSetup) {
return [];
}
return Array.from(aiSetup.children)
.map((element) => {
const testId = element.getAttribute("data-testid");
if (testId?.startsWith("onboarding-provider-card-")) {
return testId.replace("onboarding-provider-card-", "");
}
if (testId === "claude-cli-provider-card") {
return "claude-cli";
}
return null;
})
.filter((providerId): providerId is string => providerId !== null);
}
interface AuthOnboardingHarnessProps {
openModelOnboardingSpy?: () => void;
openSettingsSpy?: (section?: string) => void;
@@ -385,6 +413,79 @@ describe("onboarding flow integration", () => {
});
});
describe("AI setup provider ordering", () => {
it("renders providers in curated onboarding order with connected-first priority and claude-cli near top", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
{ id: "moonshot", name: "Moonshot", authenticated: false, type: "oauth" },
{ id: "claude-cli", name: "Anthropic — via Claude CLI", authenticated: false, type: "cli" },
{ id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth" },
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "zai", name: "Zhipu AI", authenticated: false, type: "api_key" },
{ id: "minimax", name: "MiniMax", authenticated: false, type: "api_key" },
{ id: "google", name: "Google", authenticated: false, type: "oauth" },
{ id: "gemini", name: "Gemini", authenticated: true, type: "oauth" },
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" },
{ id: "github", name: "GitHub", authenticated: true, type: "oauth" },
],
});
renderModal();
await waitFor(() => {
expect(screen.getByText("Set Up AI")).toBeInTheDocument();
});
const providerOrder = getAiSetupProviderOrder();
expect(providerOrder).toEqual([
"anthropic",
"gemini",
"openrouter",
"claude-cli",
"openai-codex",
"google",
"minimax",
"moonshot",
"zai",
"openai",
]);
expect(providerOrder).not.toContain("github");
expect(providerOrder.indexOf("openrouter")).toBeLessThan(providerOrder.indexOf("claude-cli"));
expect(providerOrder.indexOf("claude-cli")).toBeLessThan(providerOrder.indexOf("openai-codex"));
expect(providerOrder.indexOf("openrouter")).toBeLessThan(providerOrder.indexOf("openai"));
});
it("preserves provider-specific controls and status badges after reordering", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth" },
{ id: "claude-cli", name: "Anthropic — via Claude CLI", authenticated: false, type: "cli" },
{ id: "minimax", name: "MiniMax", authenticated: false, type: "api_key" },
],
});
renderModal();
await waitFor(() => {
expect(screen.getByTestId("onboarding-provider-card-openai-codex")).toBeInTheDocument();
});
const codexCard = screen.getByTestId("onboarding-provider-card-openai-codex");
expect(within(codexCard).getByRole("button", { name: "Login" })).toBeInTheDocument();
expect(within(codexCard).getByTestId("provider-status-badge")).toHaveTextContent("Not connected");
expect(screen.getByTestId("claude-cli-provider-card")).toHaveAttribute("data-authenticated", "false");
const minimaxCard = screen.getByTestId("onboarding-provider-card-minimax");
expect(within(minimaxCard).getByTestId("onboarding-apikey-input-minimax")).toBeInTheDocument();
expect(within(minimaxCard).getByTestId("onboarding-apikey-save-minimax")).toBeInTheDocument();
expect(within(minimaxCard).getByTestId("provider-status-badge")).toHaveTextContent("Not connected");
});
});
describe("dismissal flow", () => {
it("dismissal flow: dismissing on GitHub step saves state with currentStep=github", async () => {
const renderResult = renderModal();