From 970bd7e8dc1c9950bae3105e3d4a49c70313120d Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 2 Oct 2026 22:13:57 -0500 Subject: [PATCH] Unify new-agent connection mode switching Co-Authored-By: Paperclip --- doc/connections/AI-CONNECTIONS.md | 6 ++ .../ai-connections/AiConnectionField.test.tsx | 11 +++- .../ai-connections/AiConnectionField.tsx | 8 ++- .../ai-connections/AiProviderSetup.test.tsx | 16 +++++ .../ai-connections/AiProviderSetup.tsx | 4 ++ .../AgentProviderConnection.test.tsx | 51 +++++++++++++++- .../new-agent/AgentProviderConnection.tsx | 61 ++++++++++++------- ui/src/components/new-agent/NewAgentSetup.tsx | 23 +++++-- .../onboarding/CredentialModeLink.tsx | 40 +++++++++--- .../onboarding/ModelSourceTiles.tsx | 10 +-- ...provider-routing-06-production.stories.tsx | 46 +++++++++++++- 11 files changed, 233 insertions(+), 43 deletions(-) diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index 51b1b389c4a..d70734f0458 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -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 diff --git a/ui/src/components/ai-connections/AiConnectionField.test.tsx b/ui/src/components/ai-connections/AiConnectionField.test.tsx index f68283bbaac..9603e6fd1f5 100644 --- a/ui/src/components/ai-connections/AiConnectionField.test.tsx +++ b/ui/src/components/ai-connections/AiConnectionField.test.tsx @@ -18,6 +18,7 @@ vi.mock("./AiConnectionCredentialStep", () => ({ }, })); vi.mock("./AiConnectionManagement", () => ({ AiConnectionLegacyNotice: () => null })); +vi.mock("./AiProviderSetup", () => ({ AiProviderSetup: () =>
Advanced provider setup
})); 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( )); 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"); diff --git a/ui/src/components/ai-connections/AiConnectionField.tsx b/ui/src/components/ai-connections/AiConnectionField.tsx index 902cd7884e0..053d986d3ab 100644 --- a/ui/src/components/ai-connections/AiConnectionField.tsx +++ b/ui/src/components/ai-connections/AiConnectionField.tsx @@ -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(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({ {advancedSetup ? 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); diff --git a/ui/src/components/ai-connections/AiProviderSetup.test.tsx b/ui/src/components/ai-connections/AiProviderSetup.test.tsx index 31335841a46..eb60c665dad 100644 --- a/ui/src/components/ai-connections/AiProviderSetup.test.tsx +++ b/ui/src/components/ai-connections/AiProviderSetup.test.tsx @@ -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( {}} onComplete={() => {}} />)); + 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 } } }); diff --git a/ui/src/components/ai-connections/AiProviderSetup.tsx b/ui/src/components/ai-connections/AiProviderSetup.tsx index cb17432ca49..76dfc0388d5 100644 --- a/ui/src/components/ai-connections/AiProviderSetup.tsx +++ b/ui/src/components/ai-connections/AiProviderSetup.tsx @@ -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 &&

Could not load agents.

} {step === "provider" ? ( <> + {advancedOnly ? choices(true) : <> {choices(false)}
@@ -289,6 +292,7 @@ export function AiProviderSetup({
{choices(true)}
+ } diff --git a/ui/src/components/new-agent/AgentProviderConnection.test.tsx b/ui/src/components/new-agent/AgentProviderConnection.test.tsx index fdca64942ab..f763b620098 100644 --- a/ui/src/components/new-agent/AgentProviderConnection.test.tsx +++ b/ui/src/components/new-agent/AgentProviderConnection.test.tsx @@ -55,6 +55,7 @@ async function mount( localEnvironment = false, deploymentMode: "local_trusted" | "authenticated" = "local_trusted", localAiLoginSupported = true, + advancedConnection?: Parameters[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} /> , ), @@ -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:
Advanced connection picker
, + }); + 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:
Advanced connection picker
, 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:
Advanced connection picker
, + 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) => { diff --git a/ui/src/components/new-agent/AgentProviderConnection.tsx b/ui/src/components/new-agent/AgentProviderConnection.tsx index 9cd1c12b7ed..c5614ac412b 100644 --- a/ui/src/components/new-agent/AgentProviderConnection.tsx +++ b/ui/src/components/new-agent/AgentProviderConnection.tsx @@ -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; 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(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 (
, + label: advanced ? "Advanced connection" : provider, + icon: advanced ? : , }, ]} - 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 && (
- { - cancel(); - setOpened(opened); - savedManagedAccount.current = null; - setMethod(next); - setApiKey(""); - setStoredConnection(null); - setError(null); - }} - /> + {advancedConnection + ? + : }
)} + {advanced && advancedConnection ? <> +
{advancedConnection.content}
+ { + if (advancedConnection.value) onConnected({ env: {}, aiConnection: advancedConnection.value }); + }} + /> + : <> {!opened && savedKeys.options.length > 0 && (

{savedKeys.options.length} saved API{" "} @@ -438,6 +454,7 @@ export function AgentProviderConnection({ } else void connect(); }} /> + }

); } diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index b955a3b89d2..78a0d36ece2 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -721,10 +721,6 @@ function Setup({ center /> -
- Use another connection -
{ setConnection({ env: {}, aiConnection: binding }); setRuntimeAiBinding(undefined); resetTest(); setScreen("runtime"); }} />
-
{ + setConnection({ env: {}, aiConnection: binding }); + setRuntimeAiBinding(undefined); + resetTest(); + }} + /> + ), + }} onBack={() => navigate("/agents/all")} testConnection={runTest} testError={ diff --git a/ui/src/components/onboarding/CredentialModeLink.tsx b/ui/src/components/onboarding/CredentialModeLink.tsx index f8e1111c583..5aa014bc763 100644 --- a/ui/src/components/onboarding/CredentialModeLink.tsx +++ b/ui/src/components/onboarding/CredentialModeLink.tsx @@ -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 = { - subscription: "Use API key instead", - api: "Use subscription instead", +const LINK_LABEL: Record = { + 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 = { subscription: "api", @@ -33,11 +36,32 @@ export function CredentialModeLink({ }: { mode: CredentialMode; onChange: (next: CredentialMode) => void; +}) { + return 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
+ {CONNECTION_MODES.filter(destination => destination !== mode).map(destination => ( + onChange(destination)} modes={CONNECTION_MODES} /> + ))} +
; +} + +function ModeLink({ destination, onClick, modes }: { + destination: ModelConnectionMode; + onClick: () => void; + modes: ModelConnectionMode[]; }) { return ( diff --git a/ui/src/components/onboarding/ModelSourceTiles.tsx b/ui/src/components/onboarding/ModelSourceTiles.tsx index 3f33dce8497..7aa4206a879 100644 --- a/ui/src/components/onboarding/ModelSourceTiles.tsx +++ b/ui/src/components/onboarding/ModelSourceTiles.tsx @@ -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 = { +const CREDENTIAL_TAG_LABEL: Record = { subscription: "Subscription", api: "API", + advanced: "Advanced", }; /** @@ -43,7 +45,7 @@ const CREDENTIAL_TAG_LABEL: Record = { * 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 ( @@ -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; diff --git a/ui/storybook/stories/provider-routing-06-production.stories.tsx b/ui/storybook/stories/provider-routing-06-production.stories.tsx index 292036a0064..f93028f0b75 100644 --- a/ui/storybook/stories/provider-routing-06-production.stories.tsx +++ b/ui/storybook/stories/provider-routing-06-production.stories.tsx @@ -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 ; } +function AgentConnectionModes() { + const [value, setValue] = useState(); + const [connected, setConnected] = useState(); + 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 + {}} testConnection={async () => true} onConnected={setConnected} + advancedConnection={{ value, content: }} /> + {connected &&

Continue to Configure with {connected.aiConnection?.provider}.

} +
; +} 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; +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: () => , + 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." } } },