diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index 98753282009..9396c605028 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -277,6 +277,7 @@ import { ADAPTER_AUTH_MISSING_CHECK_CODE, getEnvironmentCapabilities } from "@pa import { CLAUDE_OAUTH_TOKEN_ENV_KEY } from "./environment-variables-editor/model"; import { ONBOARDING_STORAGE_KEY, OnboardingWizard } from "./OnboardingWizard"; import { CONNECTED_HOLD_MS } from "./onboarding/onboarding-motion"; +import { TooltipProvider } from "./ui/tooltip"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -302,7 +303,7 @@ function renderWizardView(root: ReturnType, queryClient: Quer return act(async () => { root.render( - + , ); }); @@ -2155,6 +2156,90 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( return { root, queryClient }; } + it.each([["successful refresh", false], ["failed refresh and retry", true]] as const)("saves a custom gateway without probing, then explicitly tests and hires with its selected model: %s", async (_scenario, failRefresh) => { + const gatewayAccounts: import("@paperclipai/shared").AiConnectionList = { + currentUserId: "user-1", canManageConnections: true, connections: [], + }; + let lookupFails = false; + managedApi.list.mockImplementation(async () => { + if (lookupFails) throw new Error("connection lookup failed"); + return structuredClone(gatewayAccounts) as never; + }); + managedApi.create.mockImplementationOnce(async () => { + gatewayAccounts.connections = [{ + id: "managed-connection", grantId: "managed-grant", companyId: "company-new", + name: "My gateway.example.test", provider: "openai", method: "api_key", ownership: "personal", + ownerUserId: "user-1", isDefault: false, status: "connected", + routing: { kind: "gateway", protocol: "responses", auth: "bearer", baseUrl: "https://gateway.example.test/v1", models: [{ id: "gpt-5.4-mini" }] }, + }]; + lookupFails = failRefresh; + return { connectionId: "managed-connection", grantId: "managed-grant" }; + }); + mockAdapterBuild.buildAdapterConfig.mockImplementation(values => ({ provider: "codex", model: values?.model })); + const handles = await openStep4({ adapterType: "codex_local", runner: "paperclip", credentialMode: "api" }); + const click = async (label: string) => { + const button = [...document.body.querySelectorAll("button")].find(entry => entry.textContent?.trim() === label)!; + expect(button).toBeTruthy(); + await act(async () => button.click()); + await flushReact(); + }; + try { + await pickSource(/OpenAI.*API/); + await click("Use a custom gateway"); + const gatewayDialog = document.querySelector('input[aria-label="Provider URL"]')!.closest('[role="dialog"]')!; + await act(async () => { + setControlledValue(gatewayDialog.querySelector('input[aria-label="Provider URL"]')!, "https://gateway.example.test/v1"); + setControlledValue(gatewayDialog.querySelector('input[aria-label="API key"]')!, "example-gateway-key"); + }); + await click("Change"); + await act(async () => setControlledValue(document.querySelector('input[aria-label="Model IDs"]')!, "gpt-5.4-mini")); + await act(async () => gatewayDialog.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }))); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + await click("Save connection"); + expect(managedApi.create).toHaveBeenCalledExactlyOnceWith("company-new", expect.objectContaining({ + provider: "openai", method: "api_key", apiKey: "example-gateway-key", ownership: "personal", + agentIds: [expect.any(String)], routing: expect.objectContaining({ kind: "gateway", protocol: "responses", models: [{ id: "gpt-5.4-mini" }] }), + })); + if (failRefresh) { + await vi.waitFor(() => expect(document.body.textContent).toContain("Your saved gateway connection could not be loaded.")); + expect(document.querySelector('select[aria-label="Model"]')).toBeNull(); + expect([...document.body.querySelectorAll("button")].find(entry => entry.textContent?.trim() === "Connect")!.disabled).toBe(true); + expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled(); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + lookupFails = false; + await click("Retry loading connection"); + } + await vi.waitFor(() => expect(document.querySelector('select[aria-label="Model"]'), document.body.textContent ?? "").toBeTruthy()); + expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled(); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain("Gateway connection saved."); + const connect = [...document.body.querySelectorAll("button")].find(entry => entry.textContent?.trim() === "Connect")!; + expect(connect.disabled).toBe(true); + const select = document.querySelector('select[aria-label="Model"]') as HTMLSelectElement; + expect([...select.options].map(option => option.value)).toEqual(["", "gpt-5.4-mini"]); + await act(async () => { select.value = "gpt-5.4-mini"; select.dispatchEvent(new Event("change", { bubbles: true })); }); + await flushReact(); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + expect(JSON.stringify(window.localStorage)).not.toContain("example-gateway-key"); + await click("Connect"); + const binding = { provider: "openai", method: "api_key", mode: "delegated", connectionId: "managed-connection", grantId: "managed-grant" }; + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledExactlyOnceWith("company-new", "codex_local", expect.objectContaining({ + environmentId: "env-sandbox-1", aiConnection: binding, adapterConfig: expect.objectContaining({ model: "gpt-5.4-mini" }), + })); + expect(mockAgentsApi.hire).toHaveBeenCalledExactlyOnceWith("company-new", expect.objectContaining({ + runtimeConfig: expect.objectContaining({ aiConnection: binding }), adapterConfig: expect.objectContaining({ model: "gpt-5.4-mini" }), + })); + expect(JSON.stringify(mockAgentsApi.hire.mock.calls)).not.toContain("example-gateway-key"); + } finally { + await act(async () => handles.root.unmount()); + managedApi.list.mockResolvedValue({ currentUserId: "user-1", connections: [] }); + } + }, 15_000); + it.each([["paperclip", "image"], ["legacy", "image"], ["paperclip", "snapshot"], ["legacy", "snapshot"]] as const)("qualifies managed Claude during Connect and tests/hire with measured %s configuration from %s", async (measured, source) => { mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!)); mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config: { diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index cb74154cc40..9516dc8aef8 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -9,11 +9,13 @@ import { LocalProviderLoginInstructions } from "./AdapterLoginChrome"; import { useLocalAiLogin } from "./ai-connections/useLocalAiLogin"; import { aiConnectionsApi } from "@/api/ai-connections"; import { AiConnectionField, aiProviderForAdapter } from "./ai-connections/AiConnectionField"; +import { useConnectionModelsState } from "./ai-connections/useConnectionModels"; import { PAPERCLIP_RUNNER_ACPX_PROFILES } from "@paperclipai/adapter-utils"; import { aiConnectionBindingSchema } from "@paperclipai/shared"; import type { AiConnectionBinding } from "@paperclipai/shared"; import { storeProviderApiKey } from "../lib/provider-credential"; import { SavedProviderKeySelect, useSavedProviderKeys } from "./onboarding/SavedProviderKeySelect"; +import { OnboardingGatewaySetup } from "./onboarding/OnboardingGatewaySetup"; import { randomAgentAppearance, resolveAgentAppearance, agentAppearanceSchema } from "@paperclipai/shared"; import { OnboardingCharacter } from "./onboarding/OnboardingCharacter"; import { useEffect, useState, useMemo, useRef } from "react"; @@ -29,7 +31,7 @@ import type { InstanceSettings, } from "@paperclipai/shared"; import { AGENT_ROLES, AGENT_ROLE_LABELS, ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared"; -import { AdapterLoginPanel } from "./AgentConfigForm"; +import { AdapterLoginPanel, ModelDropdown } from "./AgentConfigForm"; import { CONNECT_SOURCE_NAMES, OnboardingCardField, @@ -640,6 +642,9 @@ function OnboardingWizardInner({ const [setupNameConflict, setSetupNameConflict] = useState(null); const isCopilot = adapterType === "paperclip_runner"; const [copilotConnection, setCopilotConnection] = useState(); + const [gatewayConnection, setGatewayConnection] = useState<{ + companyId: string; adapterType: string; binding: AiConnectionBinding; + }>(); const savedNativeRunnerDraft = saved?.adapterType === "paperclip_runner" && !(saved?.onboardingCopilot === true && copilotOnboardingQualified); const [cwd, setCwd] = useState((saved?.cwd as string) ?? ""); const [model, setModel] = useState( @@ -790,6 +795,7 @@ function OnboardingWizardInner({ const managedSubscriptionProvider = managedProvider === "anthropic" || managedProvider === "openai" || managedProvider === "xai" ? managedProvider : undefined; function managedBindingForStep(): AiConnectionBinding | undefined { if (isCopilot) return copilotConnection; + if (credentialMode === "api" && gatewayConnection?.companyId === createdCompanyId && gatewayConnection.adapterType === adapterType) return gatewayConnection.binding; const restoredBinding = restoredSetup?.companyId === createdCompanyId && agentHarnessType(restoredSetup.adapterType, restoredSetup.adapterConfig) === agentHarnessType(adapterType, {}) ? restoredSetup.runtimeConfig?.aiConnection as AiConnectionBinding | undefined : undefined; @@ -805,6 +811,17 @@ function OnboardingWizardInner({ ? apiKeySecretRef.current.aiConnection : undefined); return savedSubscription?.aiConnection ?? (managedSubscriptionRef.current?.companyId === createdCompanyId && managedSubscriptionRef.current.binding.provider === managedProvider ? managedSubscriptionRef.current.binding : undefined); } + const connectionModelState = useConnectionModelsState( + effectiveOnboardingOpen && step === 4 && credentialMode === "api" ? createdCompanyId : null, + managedBindingForStep(), adapterType, + ); + const connectionModels = connectionModelState.connectionModels; + const hasGatewaySelection = credentialMode === "api" && gatewayConnection?.companyId === createdCompanyId && gatewayConnection.adapterType === adapterType; + const gatewayLookupFailed = hasGatewaySelection && !connectionModelState.refreshing + && Boolean(connectionModelState.error || (!connectionModels && !connectionModelState.loading)); + const connectionModelReady = (!hasGatewaySelection && !connectionModels) || Boolean(connectionModels + && (!hasGatewaySelection || (!connectionModelState.error && !connectionModelState.refreshing)) + && model.trim() && (connectionModels.models.length === 0 || connectionModels.models.some(entry => entry.id === model.trim()))); createdCompanyIdRef.current = createdCompanyId; // The step the request wants, mirrored for the same reason. `initialStep` is @@ -834,6 +851,7 @@ function OnboardingWizardInner({ function clearCompanyScopedState() { setRestoredSetup(null); setCopilotConnection(undefined); + setGatewayConnection(undefined); setAgentAppearance(randomAgentAppearance()); setCreatedCompanyPrefix(null); setCompanyName(""); @@ -1292,7 +1310,7 @@ function OnboardingWizardInner({ const restoredApiCredential = restoredSetup?.companyId === createdCompanyId && agentHarnessType(restoredSetup.adapterType, restoredSetup.adapterConfig) === agentHarnessType(adapterType, isCopilot ? { provider: "acpx", acpxAgent: "copilot" } : {}) && !selectedSavedKey && (restoredSetup.adapterConfig.env as Record | undefined)?.[apiKeyEnvKeyFor(adapterType)]; - const connectStepReady = Boolean(setupPrepared || createdAgentId) && sourceSelected && !adapterEnvLoading && !savedKeys.loading && runnerDiscoveryReady && (!isCopilot || copilotModelReady); + const connectStepReady = Boolean(setupPrepared || createdAgentId) && sourceSelected && !adapterEnvLoading && !savedKeys.loading && runnerDiscoveryReady && (!isCopilot || copilotModelReady) && connectionModelReady; useEffect(() => { if (step !== 4 || !createdCompanyId || !agentName.trim() || !adapterRegistryLoaded @@ -1744,7 +1762,7 @@ function OnboardingWizardInner({ setAdapterEnvResult(null); adapterEnvResultAppliedStoredLoginRef.current = false; if (adapterEnvErrorSelectionRef.current !== adapterSetupSelectionRef.current) setAdapterEnvError(null); - }, [step, adapterType, runner, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id, createdCompanyId, copilotConnection, resolvedLoginEnvironmentId, runner === "auto" ? runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner : undefined]); + }, [step, adapterType, runner, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id, createdCompanyId, copilotConnection, gatewayConnection, resolvedLoginEnvironmentId, runner === "auto" ? runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner : undefined]); /** * Leaving the step puts the row back to a question. @@ -2332,6 +2350,10 @@ function OnboardingWizardInner({ async function handleGiveHeartbeat() { if (!createdCompanyId) return; if (!setupPrepared && !createdAgentId) return; + if (!connectionModelReady) { + setError("Choose a model for this gateway connection before connecting your agent."); + return; + } if (isCopilot && (!copilotOnboardingEnabled || !sourceSelected || !copilotModelReady)) { setError("Connect GitHub Copilot and select an available model before creating this agent."); return; @@ -3135,15 +3157,17 @@ function OnboardingWizardInner({ ) : credentialMode === "api" ? ( - { + {!hasGatewaySelection && { + setGatewayConnection(undefined); + if (connectionModels) setModel(""); setSelectedSavedKey(createdCompanyId ? { companyId: createdCompanyId, envKey: apiKeyEnvKeyFor(adapterType), id } : null); setApiKey(""); - }} /> - {!selectedApiKey && } + {!selectedApiKey && !hasGatewaySelection && !connectionModels && handleConnectStepPrimary()} />} + {hasGatewaySelection && (gatewayLookupFailed ? <> +

Your saved gateway connection could not be loaded. Retry to choose a model and connect your agent.

+ + :

{connectionModelState.refreshing + ? "Loading your saved gateway connection…" + : "Gateway connection saved. Choose a model, then Connect to test it and finish setting up your agent."}

)} + {connectionModels && } + {createdCompanyId && (adapterType === "codex_local" || adapterType === "claude_local") && { + setGatewayConnection({ companyId: createdCompanyId, adapterType, binding }); + setApiKey(""); + setModel(""); + setError(null); + }} />} + {(hasGatewaySelection || connectionModels) && }
) : connectStepNeedsLogin && createdCompanyId && diff --git a/ui/src/components/ai-connections/AiProviderSetup.tsx b/ui/src/components/ai-connections/AiProviderSetup.tsx index 1899864550e..4c1dda0cdf8 100644 --- a/ui/src/components/ai-connections/AiProviderSetup.tsx +++ b/ui/src/components/ai-connections/AiProviderSetup.tsx @@ -73,6 +73,7 @@ export function AiProviderSetup({ initialProtocol, providerLabel, advancedOnly = false, + submitLabel, reconnect, onCancel, onComplete, @@ -84,6 +85,7 @@ export function AiProviderSetup({ initialProtocol?: AiProviderRouting["protocol"]; providerLabel?: string; advancedOnly?: boolean; + submitLabel?: string; reconnect?: AiManagedConnectionSummary; onCancel: () => void; onComplete: ( @@ -455,10 +457,10 @@ export function AiProviderSetup({ } > {save.isPending - ? "Connecting…" + ? submitLabel ? "Saving…" : "Connecting…" : reconnect ? "Reconnect" - : "Connect"} + : submitLabel ?? "Connect"} diff --git a/ui/src/components/ai-connections/useConnectionModels.ts b/ui/src/components/ai-connections/useConnectionModels.ts index 0e53b0842bd..8202bfa6a04 100644 --- a/ui/src/components/ai-connections/useConnectionModels.ts +++ b/ui/src/components/ai-connections/useConnectionModels.ts @@ -4,7 +4,7 @@ import { aiConnectionsApi } from "@/api/ai-connections"; import { agentsApi } from "@/api/agents"; import { queryKeys } from "@/lib/queryKeys"; -export function useConnectionModels( +export function useConnectionModelsState( companyId: string | null | undefined, binding: AiConnectionBinding | undefined, harness: string, @@ -38,7 +38,7 @@ export function useConnectionModels( const modelOptions = discover ? (catalog.data ?? []).map((m) => ({ ...m, id: ["opencode_local", "pi_local"].includes(harness) ? m.id : m.id.replace(/^openrouter\//, "") })) : routing?.models ?? []; - return effectiveRouting + const connectionModels = effectiveRouting ? { models: modelOptions.map((m) => ({ id: aiRoutingModel(effectiveRouting, harness, m.id), @@ -52,4 +52,19 @@ export function useConnectionModels( aiRoutingModel(effectiveRouting, harness, model), } : undefined; + return { + connectionModels, + loading: accounts.isLoading, + refreshing: accounts.isFetching, + error: accounts.error, + refresh: async () => { await accounts.refetch(); }, + }; +} + +export function useConnectionModels( + companyId: string | null | undefined, + binding: AiConnectionBinding | undefined, + harness: string, +) { + return useConnectionModelsState(companyId, binding, harness).connectionModels; } diff --git a/ui/src/components/onboarding/OnboardingGatewaySetup.tsx b/ui/src/components/onboarding/OnboardingGatewaySetup.tsx new file mode 100644 index 00000000000..e38fabca600 --- /dev/null +++ b/ui/src/components/onboarding/OnboardingGatewaySetup.tsx @@ -0,0 +1,40 @@ +import { useState } from "react"; +import type { AiConnectionBinding } from "@paperclipai/shared"; +import { AiProviderSetup } from "../ai-connections/AiProviderSetup"; +import { Button } from "../ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog"; + +export function OnboardingGatewaySetup({ + companyId, + agentId, + environmentId, + adapterType, + disabled, + onComplete, +}: { + companyId: string; + agentId?: string; + environmentId?: string; + adapterType: "codex_local" | "claude_local"; + disabled?: boolean; + onComplete: (binding: Exclude) => void; +}) { + const [open, setOpen] = useState(false); + return <> + + + event.stopPropagation()}> + + Custom gateway connection + Save the connection, then choose a model and test it with your agent. + + setOpen(false)} + onComplete={binding => { setOpen(false); onComplete(binding); }} /> + + + ; +}