Unify new-agent connection mode switching

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-02 22:13:57 -05:00
1 parent cba9868517
commit 970bd7e8dc
11 files changed
+233 -43

No files matched your search

+6
View File
@@ -10,6 +10,12 @@ homes. Managed default/shared accounts are additional choices in that same
selector. Selecting “Sign in to another account” survives background refreshes;
Claude authorization paste keeps upstream's immediate Connecting feedback.
New-agent Connect offers subscription, API key, and advanced connection as
equivalent modes. “Use advanced connection instead” sits below the native
credential switch; it opens the existing compatible-connection picker and
provider setup. Only the selected mode's form is shown. Execution environment
selection lives in Configure.
Storybook's simulated controllers and page annotations do not run in the app.
## Compatibility and selection
@@ -18,6 +18,7 @@ vi.mock("./AiConnectionCredentialStep", () => ({
},
}));
vi.mock("./AiConnectionManagement", () => ({ AiConnectionLegacyNotice: () => null }));
vi.mock("./AiProviderSetup", () => ({ AiProviderSetup: () => <div>Advanced provider setup</div> }));
vi.mock("@/pages/apps/AppLogo", () => ({ AppLogo: () => null }));
let root: Root;
@@ -36,10 +37,11 @@ async function settle() {
flushSync(() => {});
}
}
async function mount(connections: AiManagedConnectionSummary[], canManageConnections = true) {
async function mount(connections: AiManagedConnectionSummary[], canManageConnections = true, preferAdvanced = false) {
mocks.list.mockResolvedValue({ currentUserId: "owner", connections, canManageConnections });
flushSync(() => root.render(<QueryClientProvider client={client}>
<AiConnectionField companyId="company" agentId="agent" agentName="Nova" adapterType="claude_local"
preferAdvanced={preferAdvanced}
value={{ provider: "anthropic", method: "subscription", mode: "responsible_user" }} onChange={onChange} />
</QueryClientProvider>));
await settle();
@@ -80,6 +82,13 @@ it("reconnects the unavailable personal default in place", async () => {
expect(onChange).toHaveBeenCalledWith({ provider: "anthropic", method: "subscription", mode: "responsible_user" });
});
it("opens provider setup directly when connecting from the advanced mode", async () => {
await mount([], true, true);
await click("Connect another account");
expect(document.body.textContent).toContain("Advanced provider setup");
expect(credentialProps).toBeUndefined();
});
it("selects the returned new grant and actual method before adopting the personal default", async () => {
await mount([account()]);
await click("Connect another account");
@@ -48,6 +48,7 @@ export function AiConnectionField({
environmentId,
legacy = false,
readOnly = false,
preferAdvanced = false,
}: {
companyId: string;
agentId?: string;
@@ -59,6 +60,7 @@ export function AiConnectionField({
environmentId?: string;
legacy?: boolean;
readOnly?: boolean;
preferAdvanced?: boolean;
}) {
const provider = aiProviderForAdapter(adapterType);
const returnFocus = useRef<HTMLElement | null>(null);
@@ -97,7 +99,7 @@ export function AiConnectionField({
const openConnection = (reconnect?: AiManagedConnectionSummary) => {
returnFocus.current = document.activeElement as HTMLElement;
setReconnecting(reconnect);
setAdvancedSetup(!reconnect && provider === "openrouter");
setAdvancedSetup(!reconnect && (preferAdvanced || provider === "openrouter"));
setAllAgents(accounts.data?.canManageConnections ?? false);
setSavedAccount(undefined);
selectDefault.reset();
@@ -188,7 +190,9 @@ export function AiConnectionField({
</DialogHeader>
{advancedSetup ? <AiProviderSetup
companyId={companyId} agentId={agentId} environmentId={environmentId}
onCancel={() => setAdvancedSetup(false)}
advancedOnly={preferAdvanced}
initialProtocol={adapterType === "claude_local" ? "messages" : adapterType === "codex_local" ? "responses" : "chat"}
onCancel={() => preferAdvanced ? setConnecting(false) : setAdvancedSetup(false)}
onComplete={binding => {
void client.invalidateQueries({ queryKey: ["ai-connections", companyId] });
setConnecting(false);
@@ -33,6 +33,22 @@ it("reconnects an older OpenRouter account without adding routing metadata", asy
});
const providerSources = ["openrouter", "bedrock", "responses-api", "messages-api", "chat-completions-api", "local"];
it("shows advanced providers directly when entered from advanced connection mode", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
client.setQueryData(["ai-connections", "company", undefined], { canManageConnections: true, connections: [] });
client.setQueryData(["agents", "company", "provider-access"], []);
const container = document.createElement("div");
const root = createRoot(container);
try {
await act(async () => root.render(<QueryClientProvider client={client}><AiProviderSetup companyId="company" advancedOnly onCancel={() => {}} onComplete={() => {}} /></QueryClientProvider>));
const choices = Array.from(container.querySelectorAll("button[aria-label]")).map(button => button.getAttribute("aria-label"));
expect(choices).toEqual(["OpenRouter", "Amazon Bedrock", "Custom gateway", "Local endpoint"]);
expect(container.querySelector("details")).toBeNull();
} finally {
await act(async () => root.unmount()); client.clear();
}
});
it.each(providerSources)("opens %s directly with shared access folded under Advanced", async source => {
const preset = aiProviderSetupPreset(source)!;
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
@@ -72,6 +72,7 @@ export function AiProviderSetup({
initialProvider,
initialProtocol,
providerLabel,
advancedOnly = false,
reconnect,
onCancel,
onComplete,
@@ -82,6 +83,7 @@ export function AiProviderSetup({
initialProvider?: ProviderChoice;
initialProtocol?: AiProviderRouting["protocol"];
providerLabel?: string;
advancedOnly?: boolean;
reconnect?: AiManagedConnectionSummary;
onCancel: () => void;
onComplete: (
@@ -282,6 +284,7 @@ export function AiProviderSetup({
{agents.isError && <p role="alert" className="text-sm text-destructive">Could not load agents. <Button type="button" variant="ghost" onClick={() => void agents.refetch()}>Retry</Button></p>}
{step === "provider" ? (
<>
{advancedOnly ? choices(true) : <>
{choices(false)}
<details>
<summary className="cursor-pointer text-sm text-muted-foreground">
@@ -289,6 +292,7 @@ export function AiProviderSetup({
</summary>
<div className="pt-4">{choices(true)}</div>
</details>
</>}
<Button type="button" variant="ghost" onClick={onCancel}>
Cancel
</Button>
@@ -55,6 +55,7 @@ async function mount(
localEnvironment = false,
deploymentMode: "local_trusted" | "authenticated" = "local_trusted",
localAiLoginSupported = true,
advancedConnection?: Parameters<typeof AgentProviderConnection>[0]["advancedConnection"],
) {
const key =
adapterType === "claude_local" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
@@ -127,6 +128,7 @@ async function mount(
testConnection={test}
onConnected={connected}
managedAccount={managedAccount}
advancedConnection={advancedConnection}
/>
</QueryClientProvider>,
),
@@ -138,7 +140,7 @@ async function mount(
}
function click(text: string) {
const button = [...host.querySelectorAll("button")].find((b) =>
b.textContent?.includes(text),
b.getAttribute("aria-label") ? b.getAttribute("aria-label") === text : b.textContent?.includes(text),
)!;
expect(button).toBeTruthy();
flushSync(() => button.click());
@@ -149,6 +151,53 @@ function openProvider() {
);
}
describe("AgentProviderConnection reuse", () => {
it("switches subscription, API key, and advanced connections in the same place", async () => {
await mount("codex_local", false, true, true, true, false, undefined, false, "local_trusted", true, {
content: <div>Advanced connection picker</div>,
});
const modeLabels = () => Array.from(host.querySelectorAll('button[aria-label^="Use "]')).map(button => button.getAttribute("aria-label"));
expect(modeLabels()).toEqual(["Use API key instead", "Use advanced connection instead"]);
expect(host.querySelector('[aria-label="Saved subscription"]')).not.toBeNull();
expect(host.textContent).not.toContain("Advanced connection picker");
click("Use API key instead");
expect(host.querySelector('[aria-label="Saved API key"]')).not.toBeNull();
expect(host.querySelector('[aria-label="Saved subscription"]')).toBeNull();
expect(modeLabels()).toEqual(["Use subscription instead", "Use advanced connection instead"]);
click("Use advanced connection instead");
expect(host.textContent).toContain("Advanced connection picker");
expect(host.querySelector("select")).toBeNull();
expect(modeLabels()).toEqual(["Use subscription instead", "Use API key instead"]);
const useConnection = Array.from(host.querySelectorAll("button")).find(button => button.textContent?.includes("Use connection"))!;
expect(useConnection.disabled).toBe(true);
click("Use subscription instead");
expect(host.querySelector('[aria-label="Saved subscription"]')).not.toBeNull();
expect(host.textContent).not.toContain("Advanced connection picker");
});
it("continues with the selected advanced connection", async () => {
const binding = { provider: "openrouter" as const, method: "api_key" as const, mode: "shared" as const, connectionId: "router", grantId: "router-grant" };
const { connected, test } = await mount("codex_local", false, true, true, true, false, undefined, false, "local_trusted", true, {
content: <div>Advanced connection picker</div>, value: binding,
});
click("Use advanced connection instead");
click("Use connection");
expect(connected).toHaveBeenCalledExactlyOnceWith({ env: {}, aiConnection: binding });
expect(test).not.toHaveBeenCalled();
});
it("uses the saved subscription after switching back from an advanced connection", async () => {
const { connected } = await mount("codex_local", false, true, true, true, false, undefined, false, "local_trusted", true, {
content: <div>Advanced connection picker</div>,
value: { provider: "openrouter", method: "api_key", mode: "shared", connectionId: "router", grantId: "router-grant" },
});
click("Use advanced connection instead");
click("Use subscription instead");
click("Use saved subscription");
await vi.waitFor(() => expect(connected).toHaveBeenCalled());
expect(connected.mock.calls[0][0]).toMatchObject({ env: { CODEX_HOME: { type: "secret_ref", secretId: "codex-home" } } });
expect(connected.mock.calls[0][0].aiConnection).toBeUndefined();
});
it.each([
["anthropic", "claude_local"], ["openai", "codex_local"], ["xai", "grok_local"],
] as const)("lets %s recovery switch methods without overwriting the original account", async (provider, adapterType) => {
@@ -2,7 +2,8 @@ import { healthApi } from "@/api/health";
import { aiConnectionsApi } from "@/api/ai-connections";
import { useLocalAiLogin } from "../ai-connections/useLocalAiLogin";
import type { AiConnectionBinding, AiConnectionLoginIntent } from "@paperclipai/shared";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Cable } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { motion } from "motion/react";
import {
@@ -18,8 +19,8 @@ import {
OnboardingCardField,
OnboardingLoginCard,
} from "../AdapterLoginChrome";
import { ModelSourceTiles } from "../onboarding/ModelSourceTiles";
import { CredentialModeLink } from "../onboarding/CredentialModeLink";
import { ModelSourceTiles, type ModelConnectionMode } from "../onboarding/ModelSourceTiles";
import { CredentialModeLink, ConnectionModeLinks } from "../onboarding/CredentialModeLink";
import { FooterNav } from "../onboarding/FooterNav";
import { MAKE_ROOM, CARD_ENTER } from "../onboarding/onboarding-motion";
import { buildFixedClaudeOAuthBinding } from "../environment-variables-editor/model";
@@ -44,6 +45,7 @@ export function AgentProviderConnection({
testConnection,
testError,
managedAccount,
advancedConnection,
}: {
companyId: string;
adapterType: "claude_local" | "codex_local" | "grok_local";
@@ -54,6 +56,7 @@ export function AgentProviderConnection({
onBack: () => void;
testConnection: (connection: ProviderConnection) => Promise<boolean>;
testError?: string | null;
advancedConnection?: { content: ReactNode; value?: AiConnectionBinding };
/** Connections supplies its access intent; presentation and login controllers stay shared. */
managedAccount?: {
intent: AiConnectionLoginIntent;
@@ -81,6 +84,7 @@ export function AgentProviderConnection({
setLoginPhase("preparing");
};
const [methodChoice, setMethod] = useState<"subscription" | "api" | null>(managedAccount?.initialMethod === "api_key" ? "api" : managedAccount ? "subscription" : null);
const [advanced, setAdvanced] = useState(false);
const [opened, setOpened] = useState(false);
const [authorizationUrl, setAuthorizationUrl] = useState<string | null>(null);
const [loginPhase, setLoginPhase] = useState<"preparing" | "ready" | "waiting" | "connecting">("preparing");
@@ -136,7 +140,7 @@ export function AgentProviderConnection({
const localLogin = useLocalAiLogin(companyId, managedIntent ?? {
provider: aiProvider, method: "subscription", name: `My ${provider} subscription`,
ownership: "personal", agentIds: [], allAgents: true,
}, canUseLocalLogin && method === "subscription" && !savedSubscription && !storedLogin.data,
}, !advanced && canUseLocalLogin && method === "subscription" && !savedSubscription && !storedLogin.data,
{ allowHostClaude: health.data?.deploymentMode === "local_trusted" });
const auth = useQuery({
queryKey: queryKeys.agents.authSignal(
@@ -151,7 +155,7 @@ export function AgentProviderConnection({
environmentId ?? undefined,
),
retry: false,
enabled: !managedAccount,
enabled: !managedAccount && !advanced,
});
async function connect() {
if (busy || managedAccount?.disabled) return;
@@ -229,6 +233,16 @@ export function AgentProviderConnection({
!savedKeys.loading &&
!storedLogin.data &&
(Boolean(managedAccount) || auth.data?.status !== "present" || subscriptionId === "");
const switchMode = (next: ModelConnectionMode) => {
cancel();
setAdvanced(next === "advanced");
setOpened(advancedConnection ? true : opened);
savedManagedAccount.current = null;
if (next !== "advanced") setMethod(next);
setApiKey("");
setStoredConnection(null);
setError(null);
};
return (
<div className="min-w-0 max-w-full">
<ModelSourceTiles
@@ -236,31 +250,33 @@ export function AgentProviderConnection({
sources={[
{
id: adapterType,
label: provider,
icon: <AdapterMark type={adapterType} />,
label: advanced ? "Advanced connection" : provider,
icon: advanced ? <Cable className="size-6" /> : <AdapterMark type={adapterType} />,
},
]}
mode={method}
mode={advanced ? "advanced" : method}
selectedId={opened ? adapterType : null}
collapsed={opened}
onSelect={() => { if (!managedAccount?.disabled) setOpened(true); }}
onSelect={() => { if (!advanced && !managedAccount?.disabled) setOpened(true); }}
/>
{(!opened || managedAccount) && !managedAccount?.fixedMethod && (
{(!opened || managedAccount || advancedConnection) && !managedAccount?.fixedMethod && (
<div className="-ml-3 mt-1">
<CredentialModeLink
mode={method}
onChange={(next) => {
cancel();
setOpened(opened);
savedManagedAccount.current = null;
setMethod(next);
setApiKey("");
setStoredConnection(null);
setError(null);
}}
/>
{advancedConnection
? <ConnectionModeLinks mode={advanced ? "advanced" : method} onChange={switchMode} />
: <CredentialModeLink mode={method} onChange={switchMode} />}
</div>
)}
{advanced && advancedConnection ? <>
<div className="pt-5">{advancedConnection.content}</div>
<FooterNav
onBack={onBack}
primaryLabel="Use connection"
primaryDisabled={!advancedConnection.value}
onPrimary={() => {
if (advancedConnection.value) onConnected({ env: {}, aiConnection: advancedConnection.value });
}}
/>
</> : <>
{!opened && savedKeys.options.length > 0 && (
<p className="mt-2 text-sm text-muted-foreground">
{savedKeys.options.length} saved API{" "}
@@ -438,6 +454,7 @@ export function AgentProviderConnection({
} else void connect();
}}
/>
</>}
</div>
);
}
+19 -4
View File
@@ -721,10 +721,6 @@ function Setup({
center
/>
</div>
<details className="mb-5">
<summary className="cursor-pointer text-sm text-muted-foreground">Use another connection</summary>
<div className="pt-4"><AiConnectionField companyId={companyId} agentName={name} adapterType={brandType} model={model} environmentId={environmentId ?? undefined} value={aiBinding} onChange={binding => { setConnection({ env: {}, aiConnection: binding }); setRuntimeAiBinding(undefined); resetTest(); setScreen("runtime"); }} /></div>
</details>
<AgentProviderConnection
key={environmentId ?? "local"}
companyId={companyId}
@@ -732,6 +728,25 @@ function Setup({
environmentId={environmentId}
canLogin={canLogin}
localEnvironment={environment?.driver === "local"}
advancedConnection={{
value: aiBinding,
content: (
<AiConnectionField
companyId={companyId}
agentName={name}
adapterType={brandType}
model={model}
environmentId={environmentId ?? undefined}
value={aiBinding}
preferAdvanced
onChange={binding => {
setConnection({ env: {}, aiConnection: binding });
setRuntimeAiBinding(undefined);
resetTest();
}}
/>
),
}}
onBack={() => navigate("/agents/all")}
testConnection={runTest}
testError={
@@ -1,7 +1,7 @@
import { AnimatePresence, motion } from "motion/react";
import { cn } from "../../lib/utils";
import type { CredentialMode } from "./ModelSourceTiles";
import type { CredentialMode, ModelConnectionMode } from "./ModelSourceTiles";
import { LINK_LABEL_FADE_IN, LINK_LABEL_FADE_OUT } from "./onboarding-motion";
/**
@@ -17,10 +17,13 @@ import { LINK_LABEL_FADE_IN, LINK_LABEL_FADE_OUT } from "./onboarding-motion";
* holds up because they are right above it.
*/
const LINK_LABEL: Record<CredentialMode, string> = {
subscription: "Use API key instead",
api: "Use subscription instead",
const LINK_LABEL: Record<ModelConnectionMode, string> = {
subscription: "Use subscription instead",
api: "Use API key instead",
advanced: "Use advanced connection instead",
};
const NATIVE_MODES: CredentialMode[] = ["subscription", "api"];
const CONNECTION_MODES: ModelConnectionMode[] = [...NATIVE_MODES, "advanced"];
const OTHER_MODE: Record<CredentialMode, CredentialMode> = {
subscription: "api",
@@ -33,11 +36,32 @@ export function CredentialModeLink({
}: {
mode: CredentialMode;
onChange: (next: CredentialMode) => void;
}) {
return <ModeLink destination={OTHER_MODE[mode]} onClick={() => onChange(OTHER_MODE[mode])} modes={NATIVE_MODES} />;
}
/** Three equivalent routes through the new-agent connection step. */
export function ConnectionModeLinks({ mode, onChange }: {
mode: ModelConnectionMode;
onChange: (next: ModelConnectionMode) => void;
}) {
return <div className="flex flex-col items-start">
{CONNECTION_MODES.filter(destination => destination !== mode).map(destination => (
<ModeLink key={destination} destination={destination} onClick={() => onChange(destination)} modes={CONNECTION_MODES} />
))}
</div>;
}
function ModeLink({ destination, onClick, modes }: {
destination: ModelConnectionMode;
onClick: () => void;
modes: ModelConnectionMode[];
}) {
return (
<button
type="button"
onClick={() => onChange(OTHER_MODE[mode])}
aria-label={LINK_LABEL[destination]}
onClick={onClick}
className={cn(
// A grid rather than a flow of text, so both labels can occupy one cell
// and overlap during the swap. Same padding as the checkbox row this
@@ -53,7 +77,7 @@ export function CredentialModeLink({
also takes them out of the accessibility tree, leaving the button's name
to the one real label below.
*/}
{(Object.keys(LINK_LABEL) as CredentialMode[]).map((sizerMode) => (
{modes.map((sizerMode) => (
<span
key={sizerMode}
aria-hidden
@@ -65,7 +89,7 @@ export function CredentialModeLink({
<AnimatePresence initial={false} mode="sync">
<motion.span
key={mode}
key={destination}
// Left-aligned in that max-width cell, so the sentence starts at the
// same x in both states and only its tail changes.
className={cn(
@@ -81,7 +105,7 @@ export function CredentialModeLink({
animate={{ opacity: 1, transition: LINK_LABEL_FADE_IN }}
exit={{ opacity: 0, transition: LINK_LABEL_FADE_OUT }}
>
{LINK_LABEL[mode]}
{LINK_LABEL[destination]}
</motion.span>
</AnimatePresence>
</button>
@@ -21,6 +21,7 @@ import {
/** How a source gets authenticated. Every tile is in the same mode at once. */
export type CredentialMode = "subscription" | "api";
export type ModelConnectionMode = CredentialMode | "advanced";
export type ModelSource = {
id: string;
@@ -29,9 +30,10 @@ export type ModelSource = {
icon: ReactNode;
};
const CREDENTIAL_TAG_LABEL: Record<CredentialMode, string> = {
const CREDENTIAL_TAG_LABEL: Record<ModelConnectionMode, string> = {
subscription: "Subscription",
api: "API",
advanced: "Advanced",
};
/**
@@ -43,7 +45,7 @@ const CREDENTIAL_TAG_LABEL: Record<CredentialMode, string> = {
* what makes the outgoing label fall out of frame rather than slide past the
* tile's padding and over the row below.
*/
export function CredentialTag({ mode }: { mode: CredentialMode }) {
export function CredentialTag({ mode }: { mode: ModelConnectionMode }) {
return (
<span className="relative flex h-4 w-full items-center justify-center overflow-hidden text-(length:--text-micro) text-muted-foreground">
<AnimatePresence initial={false} mode="sync">
@@ -70,7 +72,7 @@ function ModelSourceTile({
settling,
}: {
source: ModelSource;
mode: CredentialMode;
mode: ModelConnectionMode;
selected: boolean;
onSelect: () => void;
buttonRef: (node: HTMLButtonElement | null) => void;
@@ -138,7 +140,7 @@ export function ModelSourceTiles({
settling = false,
}: {
sources: ModelSource[];
mode: CredentialMode;
mode: ModelConnectionMode;
/** `null` before anything has been picked — the step opens this way. */
selectedId: string | null;
onSelect: (id: string) => void;
@@ -3,6 +3,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AiProviderSetup } from "@/components/ai-connections/AiProviderSetup";
import { AiConnectionSelect } from "@/components/ai-connections/AiConnectionSelect";
import { AiConnectionField } from "@/components/ai-connections/AiConnectionField";
import { AgentProviderConnection, type ProviderConnection } from "@/components/new-agent/AgentProviderConnection";
import { useConnectionModels } from "@/components/ai-connections/useConnectionModels";
import { ModelDropdown } from "@/components/AgentConfigForm";
import { queryKeys } from "@/lib/queryKeys";
@@ -10,7 +12,7 @@ import type {
AiConnectionBinding,
AiManagedConnectionSummary,
} from "@paperclipai/shared";
import { expect, userEvent, within } from "storybook/test";
import { expect, userEvent, waitFor, within } from "storybook/test";
import { ProviderCatalogReview } from "../prototypes/provider-routing/ConnectionCatalog";
import { ReviewFrame } from "../prototypes/provider-routing/shared";
@@ -129,6 +131,27 @@ function OpenRouterModels() {
});
return <QueryClientProvider client={client}><OpenRouterModelPicker /></QueryClientProvider>;
}
function AgentConnectionModes() {
const [value, setValue] = useState<AiConnectionBinding>();
const [connected, setConnected] = useState<ProviderConnection>();
const [client] = useState(() => {
const query = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
const connections = { currentUserId: "you", connections: accounts, canManageConnections: true };
query.setQueryData(["ai-connections", companyId], connections);
query.setQueryData(["ai-connections", companyId, undefined], connections);
query.setQueryData(queryKeys.secrets.myUserSecrets(companyId), []);
query.setQueryData(queryKeys.secrets.list(companyId), []);
query.setQueryData(queryKeys.agents.authSignal(companyId, "codex_local", null), { status: "present" });
return query;
});
return <QueryClientProvider client={client}>
<AgentProviderConnection companyId={companyId} adapterType="codex_local" environmentId={null}
canLogin={false} onBack={() => {}} testConnection={async () => true} onConnected={setConnected}
advancedConnection={{ value, content: <AiConnectionField companyId={companyId} agentName="Nova"
adapterType="codex_local" value={value} onChange={setValue} preferAdvanced /> }} />
{connected && <p role="status">Continue to Configure with {connected.aiConnection?.provider}.</p>}
</QueryClientProvider>;
}
const meta = {
title: "AI Connections/Provider routing/06 Production components",
parameters: { layout: "fullscreen" },
@@ -142,6 +165,27 @@ const meta = {
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
export const NewAgentConnectionModes: Story = {
name: "New agent connection modes",
parameters: { docs: { description: { story: "Agents → New agent → Connect. The production connection step switches between saved subscriptions, API keys, and advanced connections. Environment selection belongs to Configure." } } },
render: () => <AgentConnectionModes />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("combobox", { name: "Saved subscription" })).toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Use API key instead" }));
await waitFor(() => expect(canvas.getByPlaceholderText("Enter API key here")).toBeVisible());
await userEvent.click(canvas.getByRole("button", { name: "Use advanced connection instead" }));
await expect(canvas.queryByPlaceholderText("Enter API key here")).not.toBeInTheDocument();
await expect(canvas.getByRole("button", { name: "Use connection" })).toBeDisabled();
await userEvent.click(canvas.getByRole("combobox", { name: "Connection" }));
await userEvent.click(within(canvasElement.ownerDocument.body).getByRole("option", { name: "Company OpenRouter" }));
await userEvent.click(canvas.getByRole("button", { name: "Use connection" }));
await expect(canvas.getByRole("status")).toHaveTextContent("Continue to Configure with openrouter.");
await userEvent.click(canvas.getByRole("button", { name: "Use subscription instead" }));
await expect(canvas.getByRole("combobox", { name: "Saved subscription" })).toBeVisible();
await expect(canvas.queryByRole("combobox", { name: "Connection" })).not.toBeInTheDocument();
},
};
export const OpenRouterPopularModels: Story = {
name: "OpenRouter popular models",
parameters: { docs: { description: { story: "Production model picker and connection discovery hook. The connection has no custom model list; the public catalog fixture supplies models in popularity order." } } },