mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
Unify new-agent connection mode switching
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
cba9868517
commit
970bd7e8dc
11 files changed
+233
-43
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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." } } },
|
||||
|
||||
Reference in new issue
Block a user