From 363e76858fd9b8efcddb3c464c637d48977a0a26 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 2 Jul 2026 11:38:11 +0200 Subject: [PATCH] feat(app): add Paseo Agent provider catalog UI --- packages/app/e2e/helpers/paseo-agent.ts | 52 +- .../e2e/paseo-agent-provider-config.spec.ts | 24 +- .../paseo-agent-settings-sheet-model.test.ts | 167 ++- .../paseo-agent-settings-sheet-model.ts | 97 +- .../paseo-agent-settings-sheet.test.tsx | 492 ++++++++ .../components/paseo-agent-settings-sheet.tsx | 1023 +++++++++++++++-- .../src/hooks/use-paseo-agent-providers.ts | 80 +- 7 files changed, 1742 insertions(+), 193 deletions(-) create mode 100644 packages/app/src/components/paseo-agent-settings-sheet.test.tsx diff --git a/packages/app/e2e/helpers/paseo-agent.ts b/packages/app/e2e/helpers/paseo-agent.ts index 23e93a04ca..1130fabe83 100644 --- a/packages/app/e2e/helpers/paseo-agent.ts +++ b/packages/app/e2e/helpers/paseo-agent.ts @@ -15,16 +15,22 @@ type PaseoAgentDaemonClient = Pick< | "storePaseoAgentOAuthCredential" >; -interface OpenRouterProviderInput { +interface ApiKeyProviderInput { + catalogId: string; name: string; apiKey: string; - models: string[]; + models?: string[]; +} + +interface OAuthProviderInput { + catalogId: string; + name: string; } interface ExpectedProvider { name: string; - providerType: "openai-codex" | "openrouter"; - auth: "API key configured" | "ChatGPT login stored"; + providerLabel: string; + auth: "Connected" | "Needs attention"; modelCount: number; } @@ -42,22 +48,38 @@ export async function openPaseoAgentSettings(page: Page): Promise { await expect(sheet.getByText("Paseo Agent", { exact: true })).toBeVisible(); } -export async function addOpenRouterProvider( - page: Page, - provider: OpenRouterProviderInput, -): Promise { - await page.getByRole("button", { name: "Add OpenRouter", exact: true }).click(); - await expect(page.getByText("Add OpenRouter provider", { exact: true })).toBeVisible(); +export async function addApiKeyProvider(page: Page, provider: ApiKeyProviderInput): Promise { + await page.getByRole("button", { name: "Add model provider", exact: true }).click(); + await expect(page.getByTestId("paseo-agent-provider-picker")).toBeVisible(); + await page.getByTestId(`paseo-agent-catalog-select-${provider.catalogId}`).click(); + await expect(page.getByTestId("paseo-agent-provider-form")).toBeVisible(); await page.getByLabel("Provider name").fill(provider.name); - await page.getByLabel("OpenRouter API key").fill(provider.apiKey); - await page.getByLabel("OpenRouter models").fill(provider.models.join("\n")); + await page.getByLabel("API key").fill(provider.apiKey); + if (provider.models) { + await page.getByLabel("Models").fill(provider.models.join("\n")); + } await page.getByRole("button", { name: "Save provider", exact: true }).click(); - await expect(page.getByText("Add OpenRouter provider", { exact: true })).toHaveCount(0); + await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0); await expect(page.getByText(provider.apiKey, { exact: true })).toHaveCount(0); } +export async function startOAuthProviderSignIn( + page: Page, + provider: OAuthProviderInput, +): Promise { + await page.getByRole("button", { name: "Add model provider", exact: true }).click(); + await expect(page.getByTestId("paseo-agent-provider-picker")).toBeVisible(); + await page.getByTestId(`paseo-agent-catalog-select-${provider.catalogId}`).click(); + await expect(page.getByTestId("paseo-agent-provider-form")).toBeVisible(); + + await page.getByLabel("Provider name").fill(provider.name); + await page.getByRole("button", { name: "Sign in", exact: true }).click(); + await expect(page.getByTestId("paseo-agent-oauth-user-code")).toBeVisible(); + await expect(page.getByTestId("paseo-agent-oauth-verification-link")).toBeVisible(); +} + export async function expectModelProviderListed( page: Page, expected: ExpectedProvider, @@ -66,7 +88,7 @@ export async function expectModelProviderListed( await expect( page.getByRole("listitem", { name: new RegExp( - `${expected.name}.*${expected.providerType}.*${modelLabel}.*${expected.auth}`, + `${expected.name}.*${expected.providerLabel}.*${modelLabel}.*${expected.auth}`, ), }), ).toBeVisible(); @@ -77,7 +99,7 @@ export async function seedChatGptProvider(providerName: string): Promise { try { await client.setPaseoAgentProvider({ name: providerName, - providerType: "openai-codex", + providerType: "chatgpt", options: { models: [{ id: "gpt-5.3-codex", reasoning: true }], }, diff --git a/packages/app/e2e/paseo-agent-provider-config.spec.ts b/packages/app/e2e/paseo-agent-provider-config.spec.ts index 142b7ed6ee..04a9d83d93 100644 --- a/packages/app/e2e/paseo-agent-provider-config.spec.ts +++ b/packages/app/e2e/paseo-agent-provider-config.spec.ts @@ -1,10 +1,11 @@ import { test } from "./fixtures"; import { - addOpenRouterProvider, + addApiKeyProvider, cleanupPaseoAgentProviders, expectModelProviderListed, openPaseoAgentSettings, seedChatGptProvider, + startOAuthProviderSignIn, } from "./helpers/paseo-agent"; const OPENROUTER_PROVIDER = "phase-e-openrouter-ui"; @@ -22,7 +23,8 @@ test.describe("Paseo Agent provider configuration", () => { providerNamesToCleanup.add(OPENROUTER_PROVIDER); await openPaseoAgentSettings(page); - await addOpenRouterProvider(page, { + await addApiKeyProvider(page, { + catalogId: "openrouter", name: OPENROUTER_PROVIDER, apiKey: "sk-or-phase-e-write-only", models: ["openai/gpt-4o-mini", "anthropic/claude-3.7-sonnet"], @@ -30,9 +32,19 @@ test.describe("Paseo Agent provider configuration", () => { await expectModelProviderListed(page, { name: OPENROUTER_PROVIDER, - providerType: "openrouter", + providerLabel: "OpenRouter", modelCount: 2, - auth: "API key configured", + auth: "Connected", + }); + }); + + test("starts a ChatGPT sign-in from Settings", async ({ page }) => { + providerNamesToCleanup.add(CHATGPT_PROVIDER); + + await openPaseoAgentSettings(page); + await startOAuthProviderSignIn(page, { + catalogId: "chatgpt", + name: CHATGPT_PROVIDER, }); }); @@ -44,9 +56,9 @@ test.describe("Paseo Agent provider configuration", () => { await expectModelProviderListed(page, { name: CHATGPT_PROVIDER, - providerType: "openai-codex", + providerLabel: "ChatGPT", modelCount: 1, - auth: "ChatGPT login stored", + auth: "Connected", }); }); }); diff --git a/packages/app/src/components/paseo-agent-settings-sheet-model.test.ts b/packages/app/src/components/paseo-agent-settings-sheet-model.test.ts index b9ac6da70b..e4a023ab4b 100644 --- a/packages/app/src/components/paseo-agent-settings-sheet-model.test.ts +++ b/packages/app/src/components/paseo-agent-settings-sheet-model.test.ts @@ -1,60 +1,171 @@ import { describe, expect, it } from "vitest"; +import type { + PaseoAgentCatalogEntry, + RedactedPaseoAgentProviderConfig, +} from "@getpaseo/protocol/messages"; import { - createOpenRouterProviderInput, + createPaseoAgentProviderInput, + getPaseoAgentApiKeyAuth, + getPaseoAgentOAuthAuth, + isPaseoAgentCatalogEntrySupported, parsePaseoAgentModelIds, - paseoAgentAuthLabel, + paseoAgentAuthBadge, + paseoAgentProviderLabel, } from "./paseo-agent-settings-sheet-model"; +function catalogEntry(overrides: Partial): PaseoAgentCatalogEntry { + return { + id: "catalog-alpha", + label: "Catalog Alpha", + api: "responses", + baseUrl: "https://alpha.example.test", + auth: { kind: "api_key", envVar: "ALPHA_API_KEY" }, + models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }], + ...overrides, + }; +} + +function providerConfig( + overrides: Partial, +): RedactedPaseoAgentProviderConfig { + return { + name: "catalog-alpha", + providerType: "catalog-alpha", + models: [{ id: "alpha-fast" }], + available: true, + ...overrides, + }; +} + describe("paseo-agent-settings-sheet-model", () => { + it("recognizes supported catalog auth manifests", () => { + const apiKeyEntry = catalogEntry({ + auth: { + kind: "api_key", + envVar: "ALPHA_API_KEY", + keyUrl: "https://alpha.example.test/key", + placeholder: "alpha-key", + hint: "Paste the key from Alpha", + }, + }); + const oauthEntry = catalogEntry({ + auth: { kind: "oauth", flow: "alpha-oauth" }, + }); + + expect(getPaseoAgentApiKeyAuth(apiKeyEntry)).toEqual({ + kind: "api_key", + envVar: "ALPHA_API_KEY", + keyUrl: "https://alpha.example.test/key", + placeholder: "alpha-key", + hint: "Paste the key from Alpha", + }); + expect(getPaseoAgentOAuthAuth(oauthEntry)).toEqual({ + kind: "oauth", + flow: "alpha-oauth", + }); + expect(isPaseoAgentCatalogEntrySupported(apiKeyEntry)).toBe(true); + expect(isPaseoAgentCatalogEntrySupported(oauthEntry)).toBe(true); + }); + + it("keeps unknown catalog auth kinds visible but unsupported", () => { + const entry = catalogEntry({ auth: { kind: "future_auth", prompt: "later" } }); + + expect(getPaseoAgentApiKeyAuth(entry)).toBeNull(); + expect(getPaseoAgentOAuthAuth(entry)).toBeNull(); + expect(isPaseoAgentCatalogEntrySupported(entry)).toBe(false); + }); + it("parses model ids from comma and newline separated input", () => { expect( parsePaseoAgentModelIds(` - anthropic/claude-3.7-sonnet, openai/gpt-4o - anthropic/claude-3.7-sonnet - openai/gpt-4o-mini + alpha/fast, beta/steady + alpha/fast + gamma/deep `), - ).toEqual(["anthropic/claude-3.7-sonnet", "openai/gpt-4o", "openai/gpt-4o-mini"]); + ).toEqual(["alpha/fast", "beta/steady", "gamma/deep"]); }); - it("builds the OpenRouter provider payload without an empty api key", () => { + it("builds a generic provider payload with a trimmed explicit key", () => { expect( - createOpenRouterProviderInput({ - name: " openrouter-main ", + createPaseoAgentProviderInput({ + entry: catalogEntry({ id: "catalog-beta" }), + name: " beta-main ", + apiKey: " beta-secret ", + }), + ).toEqual({ + name: "beta-main", + providerType: "catalog-beta", + options: { + apiKey: "beta-secret", + models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }], + }, + }); + }); + + it("builds a generic provider payload with custom model ids", () => { + expect( + createPaseoAgentProviderInput({ + entry: catalogEntry({ models: [] }), + name: "alpha-main", + apiKey: "alpha-secret", + modelIds: ["alpha/fast", "beta/steady"], + }), + ).toEqual({ + name: "alpha-main", + providerType: "catalog-alpha", + options: { + apiKey: "alpha-secret", + models: [{ id: "alpha/fast" }, { id: "beta/steady" }], + }, + }); + }); + + it("builds a generic provider payload with an env reference for an empty key", () => { + expect( + createPaseoAgentProviderInput({ + entry: catalogEntry({ auth: { kind: "api_key", envVar: "BETA_API_KEY" } }), + name: "beta-main", apiKey: " ", - modelIds: ["openai/gpt-4o-mini"], }), ).toEqual({ - name: "openrouter-main", - providerType: "openrouter", + name: "beta-main", + providerType: "catalog-alpha", options: { - models: [{ id: "openai/gpt-4o-mini" }], + apiKey: "$BETA_API_KEY", + models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }], }, }); }); - it("builds the OpenRouter provider payload with a trimmed api key", () => { + it("builds an oauth provider payload without key material", () => { expect( - createOpenRouterProviderInput({ - name: "openrouter-main", - apiKey: " sk-or-secret ", - modelIds: ["openai/gpt-4o-mini", "anthropic/claude-3.7-sonnet"], + createPaseoAgentProviderInput({ + entry: catalogEntry({ auth: { kind: "oauth", flow: "alpha-oauth" } }), + name: "alpha-login", }), ).toEqual({ - name: "openrouter-main", - providerType: "openrouter", + name: "alpha-login", + providerType: "catalog-alpha", options: { - apiKey: "sk-or-secret", - models: [{ id: "openai/gpt-4o-mini" }, { id: "anthropic/claude-3.7-sonnet" }], + models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }], }, }); }); - it("describes the provider auth state", () => { - expect(paseoAgentAuthLabel({ kind: "api_key", configured: true })).toBe("API key configured"); - expect(paseoAgentAuthLabel({ kind: "api_key", configured: false })).toBe("API key required"); - expect(paseoAgentAuthLabel({ kind: "oauth", configured: true })).toBe("ChatGPT login stored"); - expect(paseoAgentAuthLabel({ kind: "oauth", configured: false })).toBe("Login required"); - expect(paseoAgentAuthLabel({ kind: "none", configured: false })).toBe("No auth"); + it("uses catalog labels and generic auth badges for instance rows", () => { + expect(paseoAgentProviderLabel(providerConfig({}), catalogEntry({}))).toBe("Catalog Alpha"); + expect( + paseoAgentProviderLabel(providerConfig({ providerType: "custom-alpha" }), undefined), + ).toBe("custom-alpha"); + expect(paseoAgentAuthBadge({ kind: "api_key", configured: true })).toEqual({ + label: "Connected", + variant: "success", + }); + expect(paseoAgentAuthBadge({ kind: "oauth", configured: false })).toEqual({ + label: "Needs attention", + variant: "error", + }); + expect(paseoAgentAuthBadge(undefined)).toBeNull(); }); }); diff --git a/packages/app/src/components/paseo-agent-settings-sheet-model.ts b/packages/app/src/components/paseo-agent-settings-sheet-model.ts index d361f93ab1..15a1630eb8 100644 --- a/packages/app/src/components/paseo-agent-settings-sheet-model.ts +++ b/packages/app/src/components/paseo-agent-settings-sheet-model.ts @@ -1,17 +1,71 @@ -import type { RedactedPaseoAgentProviderConfig } from "@getpaseo/protocol/messages"; +import type { + PaseoAgentCatalogEntry, + RedactedPaseoAgentProviderConfig, +} from "@getpaseo/protocol/messages"; import type { PaseoAgentSetProviderInput } from "@/hooks/use-paseo-agent-providers"; -export function paseoAgentAuthLabel(auth: RedactedPaseoAgentProviderConfig["auth"]): string { - if (!auth) { - return "Auth state unavailable"; +export interface PaseoAgentApiKeyAuthManifest { + kind: "api_key"; + envVar: string; + keyUrl?: string; + placeholder?: string; + hint?: string; +} + +export interface PaseoAgentOAuthAuthManifest { + kind: "oauth"; + flow: string; +} + +export interface PaseoAgentAuthBadge { + label: string; + variant: "success" | "error" | "muted"; +} + +export function getPaseoAgentApiKeyAuth( + entry: PaseoAgentCatalogEntry, +): PaseoAgentApiKeyAuthManifest | null { + if (entry.auth.kind !== "api_key" || typeof entry.auth.envVar !== "string") { + return null; } - if (auth.kind === "oauth") { - return auth.configured ? "ChatGPT login stored" : "Login required"; + return { + kind: "api_key", + envVar: entry.auth.envVar, + ...(typeof entry.auth.keyUrl === "string" ? { keyUrl: entry.auth.keyUrl } : {}), + ...(typeof entry.auth.placeholder === "string" ? { placeholder: entry.auth.placeholder } : {}), + ...(typeof entry.auth.hint === "string" ? { hint: entry.auth.hint } : {}), + }; +} + +export function getPaseoAgentOAuthAuth( + entry: PaseoAgentCatalogEntry, +): PaseoAgentOAuthAuthManifest | null { + if (entry.auth.kind !== "oauth" || typeof entry.auth.flow !== "string") { + return null; } - if (auth.kind === "none") { - return "No auth"; + return { kind: "oauth", flow: entry.auth.flow }; +} + +export function isPaseoAgentCatalogEntrySupported(entry: PaseoAgentCatalogEntry): boolean { + return getPaseoAgentApiKeyAuth(entry) !== null || getPaseoAgentOAuthAuth(entry) !== null; +} + +export function paseoAgentProviderLabel( + provider: RedactedPaseoAgentProviderConfig, + catalogEntry: PaseoAgentCatalogEntry | undefined, +): string { + return catalogEntry?.label ?? provider.providerType; +} + +export function paseoAgentAuthBadge( + auth: RedactedPaseoAgentProviderConfig["auth"], +): PaseoAgentAuthBadge | null { + if (!auth || auth.kind === "none") { + return null; } - return auth.configured ? "API key configured" : "API key required"; + return auth.configured + ? { label: "Connected", variant: "success" } + : { label: "Needs attention", variant: "error" }; } export function parsePaseoAgentModelIds(raw: string): string[] { @@ -27,18 +81,29 @@ export function parsePaseoAgentModelIds(raw: string): string[] { return ids; } -export function createOpenRouterProviderInput(input: { +export function createPaseoAgentProviderInput(input: { + entry: PaseoAgentCatalogEntry; name: string; - apiKey: string; - modelIds: string[]; + apiKey?: string; + modelIds?: string[]; }): PaseoAgentSetProviderInput { - const trimmedKey = input.apiKey.trim(); + const apiKeyAuth = getPaseoAgentApiKeyAuth(input.entry); + const trimmedKey = input.apiKey?.trim() ?? ""; + let apiKey: string | undefined; + if (apiKeyAuth) { + apiKey = trimmedKey.length > 0 ? trimmedKey : `$${apiKeyAuth.envVar}`; + } + const models = + input.modelIds && input.modelIds.length > 0 + ? input.modelIds.map((id) => ({ id })) + : input.entry.models.map((model) => ({ ...model })); + return { name: input.name.trim(), - providerType: "openrouter", + providerType: input.entry.id, options: { - models: input.modelIds.map((id) => ({ id })), - ...(trimmedKey.length > 0 ? { apiKey: trimmedKey } : {}), + models, + ...(apiKey ? { apiKey } : {}), }, }; } diff --git a/packages/app/src/components/paseo-agent-settings-sheet.test.tsx b/packages/app/src/components/paseo-agent-settings-sheet.test.tsx new file mode 100644 index 0000000000..a8f04132ac --- /dev/null +++ b/packages/app/src/components/paseo-agent-settings-sheet.test.tsx @@ -0,0 +1,492 @@ +/** + * @vitest-environment jsdom + */ +import React, { type ReactNode } from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + PaseoAgentCatalogEntry, + RedactedPaseoAgentProviderConfig, +} from "@getpaseo/protocol/messages"; +import type { + PaseoAgentOAuthCompleteResult, + PaseoAgentOAuthStartResult, + PaseoAgentSetProviderInput, +} from "@/hooks/use-paseo-agent-providers"; + +interface PaseoAgentProvidersHookMock { + supported: boolean; + catalogSupported: boolean; + providers: RedactedPaseoAgentProviderConfig[]; + catalog: PaseoAgentCatalogEntry[]; + defaultModel: string | null; + isLoading: boolean; + isCatalogLoading: boolean; + error: string | null; + catalogError: string | null; + refresh: () => Promise; + setProvider: ( + input: PaseoAgentSetProviderInput, + ) => Promise; + startOAuth: (name: string) => Promise; + completeOAuth: (name: string) => Promise; +} + +const { hookState, theme } = vi.hoisted(() => { + const initialHookState: { current: PaseoAgentProvidersHookMock } = { + current: { + supported: true, + catalogSupported: true, + providers: [] as RedactedPaseoAgentProviderConfig[], + catalog: [] as PaseoAgentCatalogEntry[], + defaultModel: null as string | null, + isLoading: false, + isCatalogLoading: false, + error: null as string | null, + catalogError: null as string | null, + refresh: vi.fn(async () => undefined), + setProvider: vi.fn(async () => null as RedactedPaseoAgentProviderConfig | null), + startOAuth: vi.fn( + async (): Promise => ({ + requestId: "oauth-start", + success: true, + name: "catalog-login", + authorization: null, + error: null, + }), + ), + completeOAuth: vi.fn( + async (): Promise => ({ + requestId: "oauth-complete", + success: true, + name: "catalog-login", + auth: { kind: "oauth", configured: true }, + error: null, + }), + ), + }, + }; + + return { + hookState: initialHookState, + theme: { + spacing: { 1: 4, 2: 8, 3: 12, 4: 16 }, + borderRadius: { md: 6, lg: 8 }, + fontSize: { xs: 11, sm: 13, base: 15 }, + fontWeight: { normal: "400", medium: "500" }, + opacity: { 50: 0.5 }, + colors: { + foreground: "#fff", + foregroundMuted: "#aaa", + surface1: "#111", + surface2: "#222", + border: "#444", + destructive: "#f00", + statusSuccess: "#0f0", + }, + }, + }; +}); + +vi.mock("react-native", () => ({ + View: ({ + children, + testID, + role, + accessibilityRole, + accessibilityLabel, + }: { + children?: ReactNode; + testID?: string; + role?: string; + accessibilityRole?: string; + accessibilityLabel?: string; + }) => + React.createElement( + "div", + { + "data-testid": testID, + role: role ?? accessibilityRole, + "aria-label": accessibilityLabel, + }, + children, + ), + Text: ({ children, testID }: { children?: ReactNode; testID?: string; numberOfLines?: number }) => + React.createElement("span", { "data-testid": testID }, children), +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => + typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, + }, + withUnistyles: + (Component: React.ComponentType>) => + ({ + uniProps, + ...rest + }: { + uniProps?: (theme: unknown) => Record; + } & Record) => { + const themed = uniProps ? uniProps(theme) : {}; + return React.createElement(Component, { ...rest, ...themed }); + }, +})); + +vi.mock("lucide-react-native", () => { + const icon = (name: string) => { + const Icon = () => React.createElement("span", { "data-icon": name }); + Icon.displayName = name; + return Icon; + }; + return { + Bot: icon("Bot"), + Plus: icon("Plus"), + }; +}); + +vi.mock("@/components/adaptive-modal-sheet", () => ({ + AdaptiveModalSheet: ({ + visible, + header, + children, + footer, + testID, + }: { + visible: boolean; + header?: { title: string; back?: { label?: string; onPress: () => void } }; + children: ReactNode; + footer?: ReactNode; + testID?: string; + }) => + visible ? ( +
+

{header?.title}

+ {header?.back ? ( + + ) : null} + {children} + {footer ?
{footer}
: null} +
+ ) : null, + AdaptiveTextInput: ({ + value, + onChangeText, + accessibilityLabel, + testID, + placeholder, + secureTextEntry, + editable = true, + multiline, + }: { + value?: string; + onChangeText?: (value: string) => void; + accessibilityLabel?: string; + testID?: string; + placeholder?: string; + secureTextEntry?: boolean; + editable?: boolean; + multiline?: boolean; + }) => { + function handleChange(event: React.ChangeEvent): void { + onChangeText?.(event.currentTarget.value); + } + + const inputProps = { + "aria-label": accessibilityLabel, + "data-testid": testID, + disabled: !editable, + onChange: handleChange, + placeholder, + value: value ?? "", + }; + + if (multiline) { + return React.createElement("textarea", inputProps); + } + + return React.createElement("input", { + ...inputProps, + type: secureTextEntry ? "password" : "text", + }); + }, +})); + +vi.mock("@/components/provider-icons", () => ({ + getProviderIcon: (provider: string) => () => + React.createElement("span", { "data-icon": `provider-${provider}` }), +})); + +vi.mock("@/components/provider-icon-name", () => ({ + resolveProviderIconName: (provider: string) => + provider === "known-icon" ? { kind: "builtin", id: provider } : { kind: "bot" }, +})); + +vi.mock("@/components/ui/external-link", () => ({ + ExternalLink: ({ + href, + label, + testID, + accessibilityLabel, + }: { + href: string; + label: string; + testID?: string; + accessibilityLabel?: string; + }) => ( + + {label} + + ), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ + children, + disabled, + onPress, + testID, + }: { + children?: ReactNode; + disabled?: boolean; + onPress?: () => void; + testID?: string; + }) => ( + + ), +})); + +vi.mock("@/components/ui/status-badge", () => ({ + StatusBadge: ({ label }: { label: string }) => {label}, +})); + +vi.mock("@/hooks/use-paseo-agent-providers", () => ({ + usePaseoAgentProviders: () => hookState.current, +})); + +import { PaseoAgentSettingsSheet } from "./paseo-agent-settings-sheet"; + +function catalogEntry(overrides: Partial): PaseoAgentCatalogEntry { + return { + id: "catalog-alpha", + label: "Catalog Alpha", + iconName: "known-icon", + docsUrl: "https://alpha.example.test/docs", + api: "responses", + baseUrl: "https://alpha.example.test", + auth: { kind: "api_key", envVar: "ALPHA_API_KEY", keyUrl: "https://alpha.example.test/key" }, + models: [{ id: "alpha-fast", label: "Alpha Fast" }], + ...overrides, + }; +} + +function providerConfig( + overrides: Partial, +): RedactedPaseoAgentProviderConfig { + return { + name: "catalog-alpha", + providerType: "catalog-alpha", + models: [{ id: "alpha-fast" }], + auth: { kind: "api_key", configured: true }, + available: true, + ...overrides, + }; +} + +function renderSheet() { + return render(); +} + +function resetHookState() { + hookState.current.supported = true; + hookState.current.catalogSupported = true; + hookState.current.providers = []; + hookState.current.catalog = []; + hookState.current.defaultModel = null; + hookState.current.isLoading = false; + hookState.current.isCatalogLoading = false; + hookState.current.error = null; + hookState.current.catalogError = null; + hookState.current.refresh = vi.fn(async () => undefined); + hookState.current.setProvider = vi.fn(async () => providerConfig({})); + hookState.current.startOAuth = vi.fn(async () => ({ + requestId: "oauth-start", + success: true, + name: "catalog-login", + authorization: null, + error: null, + })); + hookState.current.completeOAuth = vi.fn(async () => ({ + requestId: "oauth-complete", + success: true, + name: "catalog-login", + auth: { kind: "oauth", configured: true }, + error: null, + })); +} + +describe("PaseoAgentSettingsSheet", () => { + beforeEach(() => { + resetHookState(); + }); + + afterEach(() => { + cleanup(); + }); + + it("shows the catalog feature-gate message exactly once", () => { + hookState.current.catalogSupported = false; + + renderSheet(); + + expect(screen.getAllByText("Update the Paseo daemon to use this.")).toHaveLength(1); + expect(screen.queryByRole("button", { name: "Add model provider" })).toBeNull(); + }); + + it("lists catalog entries in the provider picker and disables unknown auth kinds", () => { + hookState.current.catalog = [ + catalogEntry({ label: "Alpha Provider" }), + catalogEntry({ + id: "catalog-future", + label: "Future Provider", + auth: { kind: "future_auth", prompt: "not yet" }, + }), + ]; + + renderSheet(); + fireEvent.click(screen.getByRole("button", { name: "Add model provider" })); + + expect(screen.getByText("Alpha Provider")).toBeTruthy(); + expect(screen.getByText("Future Provider")).toBeTruthy(); + expect(screen.getByTestId("paseo-agent-catalog-docs-catalog-alpha").getAttribute("href")).toBe( + "https://alpha.example.test/docs", + ); + expect(screen.getByText("Update the app to use this provider")).toBeTruthy(); + expect( + (screen.getByTestId("paseo-agent-catalog-select-catalog-future") as HTMLButtonElement) + .disabled, + ).toBe(true); + }); + + it("submits an api-key provider with an explicit key", async () => { + hookState.current.catalog = [catalogEntry({ models: [] })]; + + renderSheet(); + fireEvent.click(screen.getByRole("button", { name: "Add model provider" })); + fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-alpha")); + fireEvent.change(screen.getByLabelText("Provider name"), { + target: { value: "alpha-main" }, + }); + fireEvent.change(screen.getByLabelText("API key"), { + target: { value: " alpha-secret " }, + }); + fireEvent.change(screen.getByLabelText("Models"), { + target: { value: "alpha/fast\nbeta/steady" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save provider" })); + + await waitFor(() => { + expect(hookState.current.setProvider).toHaveBeenCalledWith({ + name: "alpha-main", + providerType: "catalog-alpha", + options: { + apiKey: "alpha-secret", + models: [{ id: "alpha/fast" }, { id: "beta/steady" }], + }, + } satisfies PaseoAgentSetProviderInput); + }); + }); + + it("submits an empty api-key field as a host env reference", async () => { + hookState.current.catalog = [catalogEntry({})]; + + renderSheet(); + fireEvent.click(screen.getByRole("button", { name: "Add model provider" })); + fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-alpha")); + fireEvent.click(screen.getByRole("button", { name: "Save provider" })); + + await waitFor(() => { + expect(hookState.current.setProvider).toHaveBeenCalledWith({ + name: "catalog-alpha", + providerType: "catalog-alpha", + options: { + apiKey: "$ALPHA_API_KEY", + models: [{ id: "alpha-fast", label: "Alpha Fast" }], + }, + } satisfies PaseoAgentSetProviderInput); + }); + }); + + it("renders a device-code oauth authorization and completes it", async () => { + hookState.current.catalog = [ + catalogEntry({ + id: "catalog-login", + label: "Catalog Login", + auth: { kind: "oauth", flow: "login-flow" }, + }), + ]; + hookState.current.startOAuth = vi.fn(async () => ({ + requestId: "oauth-start", + success: true, + name: "catalog-login", + authorization: { + kind: "device_code", + userCode: "CODE-123", + verificationUri: "https://login.example.test/device", + intervalSeconds: 5, + expiresInSeconds: 600, + instructions: "Enter the code", + }, + error: null, + })); + + renderSheet(); + fireEvent.click(screen.getByRole("button", { name: "Add model provider" })); + fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login")); + fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + + expect((await screen.findByTestId("paseo-agent-oauth-user-code")).textContent).toBe("CODE-123"); + expect(screen.getByText("https://login.example.test/device")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Complete sign in" })); + + await waitFor(() => { + expect(hookState.current.setProvider).toHaveBeenCalledWith({ + name: "catalog-login", + providerType: "catalog-login", + options: { + models: [{ id: "alpha-fast", label: "Alpha Fast" }], + }, + } satisfies PaseoAgentSetProviderInput); + expect(hookState.current.startOAuth).toHaveBeenCalledWith("catalog-login"); + expect(hookState.current.completeOAuth).toHaveBeenCalledWith("catalog-login"); + }); + }); + + it("shows auth-state badges from configured instances", () => { + hookState.current.catalog = [ + catalogEntry({ id: "catalog-alpha", label: "Catalog Alpha" }), + catalogEntry({ id: "catalog-beta", label: "Catalog Beta" }), + ]; + hookState.current.providers = [ + providerConfig({ name: "alpha", providerType: "catalog-alpha" }), + providerConfig({ + name: "beta", + providerType: "catalog-beta", + auth: { kind: "api_key", configured: false }, + }), + providerConfig({ name: "legacy", providerType: "legacy-provider", auth: undefined }), + ]; + + renderSheet(); + + expect(screen.getByText("Catalog Alpha · 1 model")).toBeTruthy(); + expect(screen.getByText("Catalog Beta · 1 model")).toBeTruthy(); + expect(screen.getByText("legacy-provider · 1 model")).toBeTruthy(); + expect(screen.getByText("Connected")).toBeTruthy(); + expect(screen.getByText("Needs attention")).toBeTruthy(); + }); +}); diff --git a/packages/app/src/components/paseo-agent-settings-sheet.tsx b/packages/app/src/components/paseo-agent-settings-sheet.tsx index 01091618a4..eeeb3e982e 100644 --- a/packages/app/src/components/paseo-agent-settings-sheet.tsx +++ b/packages/app/src/components/paseo-agent-settings-sheet.tsx @@ -1,20 +1,37 @@ -import { useCallback, useEffect, useMemo, useReducer, useState } from "react"; -import { Text, View } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { Plus } from "lucide-react-native"; -import type { RedactedPaseoAgentProviderConfig } from "@getpaseo/protocol/messages"; +import React, { useCallback, useEffect, useMemo, useReducer, useState } from "react"; +import { Text, View, type StyleProp, type TextStyle } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Bot, Plus } from "lucide-react-native"; +import type { + PaseoAgentCatalogEntry, + RedactedPaseoAgentProviderConfig, +} from "@getpaseo/protocol/messages"; import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader, } from "@/components/adaptive-modal-sheet"; +import { getProviderIcon } from "@/components/provider-icons"; +import { resolveProviderIconName } from "@/components/provider-icon-name"; +import { ExternalLink } from "@/components/ui/external-link"; import { Button } from "@/components/ui/button"; -import { isWeb } from "@/constants/platform"; +import { StatusBadge } from "@/components/ui/status-badge"; import { usePaseoAgentProviders } from "@/hooks/use-paseo-agent-providers"; +import type { + PaseoAgentOAuthCompleteResult, + PaseoAgentOAuthStartResult, + PaseoAgentSetProviderInput, +} from "@/hooks/use-paseo-agent-providers"; +import type { Theme } from "@/styles/theme"; import { - createOpenRouterProviderInput, + type PaseoAgentApiKeyAuthManifest, + createPaseoAgentProviderInput, + getPaseoAgentApiKeyAuth, + getPaseoAgentOAuthAuth, + isPaseoAgentCatalogEntrySupported, parsePaseoAgentModelIds, - paseoAgentAuthLabel, + paseoAgentAuthBadge, + paseoAgentProviderLabel, } from "./paseo-agent-settings-sheet-model"; interface PaseoAgentSettingsSheetProps { @@ -23,22 +40,202 @@ interface PaseoAgentSettingsSheetProps { onClose: () => void; } +interface DynamicProviderIconProps { + iconKey: string; + size: number; + color?: string; +} + +interface AddSheetClosedState { + kind: "closed"; +} + +interface AddSheetPickerState { + kind: "picker"; +} + +interface AddSheetFormState { + kind: "form"; + entry: PaseoAgentCatalogEntry; + initialName?: string; + lockName: boolean; + returnToPicker: boolean; +} + +type AddSheetState = AddSheetClosedState | AddSheetPickerState | AddSheetFormState; + +interface OAuthIdleState { + status: "idle"; +} + +interface OAuthAuthorizingState { + status: "authorizing"; + authorization: PaseoAgentOAuthStartResult["authorization"]; +} + +interface OAuthCompletingState { + status: "completing"; + authorization: PaseoAgentOAuthStartResult["authorization"]; +} + +interface OAuthErrorState { + status: "error"; + message: string; + authorization: PaseoAgentOAuthStartResult["authorization"]; +} + +type OAuthState = OAuthIdleState | OAuthAuthorizingState | OAuthCompletingState | OAuthErrorState; + +interface ProviderNameAndModelsFieldsProps { + entry: PaseoAgentCatalogEntry; + name: string; + models: string; + resetKey: number; + lockName: boolean; + hasCatalogModels: boolean; + modelInputStyle: StyleProp; + onNameChange: (value: string) => void; + onModelsChange: (value: string) => void; +} + +interface ApiKeyFieldsProps { + auth: PaseoAgentApiKeyAuthManifest; + apiKey: string; + resetKey: number; + onApiKeyChange: (value: string) => void; +} + +interface OAuthFieldsProps { + oauthState: OAuthState; + activeAuthorization: PaseoAgentOAuthStartResult["authorization"]; +} + +interface ProviderFormActionsProps { + saving: boolean; + canSubmit: boolean; + canComplete: boolean; + hasApiKeyAuth: boolean; + hasOAuthAuth: boolean; + oauthStatus: OAuthState["status"]; + onClose: () => void; + onSubmitApiKey: () => void; + onStartOAuth: () => void; + onCompleteOAuth: () => void; +} + const MAIN_SNAP_POINTS = ["65%", "92%"]; -const ADD_SNAP_POINTS = ["70%", "92%"]; +const PICKER_SNAP_POINTS = ["70%", "92%"]; +const FORM_SNAP_POINTS = ["78%", "92%"]; const HEADER: SheetHeader = { title: "Paseo Agent" }; -const ADD_HEADER: SheetHeader = { title: "Add OpenRouter provider" }; -const DEFAULT_PROVIDER_NAME = "openrouter"; +const PICKER_HEADER: SheetHeader = { title: "Add model provider" }; +const CATALOG_UPDATE_MESSAGE = "Update the Paseo daemon to use this."; +const APP_UPDATE_PROVIDER_MESSAGE = "Update the app to use this provider"; + +function DynamicProviderIcon({ iconKey, size, color = "" }: DynamicProviderIconProps) { + const Icon = getProviderIcon(iconKey); + return ; +} + +const ThemedDynamicProviderIcon = withUnistyles(DynamicProviderIcon); +const ThemedBot = withUnistyles(Bot); + +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +function describeError(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} + +function modelCountLabel(count: number): string { + return count === 1 ? "1 model" : `${count} models`; +} + +function catalogModelSummary(entry: PaseoAgentCatalogEntry): string { + return entry.models.length > 0 ? modelCountLabel(entry.models.length) : "Custom models"; +} + +function hasKnownProviderIcon(iconName: string | undefined): iconName is string { + return Boolean(iconName && resolveProviderIconName(iconName).kind !== "bot"); +} + +function getAuthorizationUrl( + authorization: PaseoAgentOAuthStartResult["authorization"], +): string | null { + if (!authorization) { + return null; + } + if (typeof authorization.url === "string") { + return authorization.url; + } + if (typeof authorization.verificationUri === "string") { + return authorization.verificationUri; + } + return null; +} + +function getAuthorizationInstructions( + authorization: PaseoAgentOAuthStartResult["authorization"], +): string | null { + return authorization && typeof authorization.instructions === "string" + ? authorization.instructions + : null; +} + +function canCompleteAuthorization( + authorization: PaseoAgentOAuthStartResult["authorization"], +): boolean { + if (!authorization) { + return true; + } + if (authorization.kind === "device_code" || authorization.kind === "auth_url") { + return true; + } + const hasUrl = getAuthorizationUrl(authorization) !== null; + const hasInstructions = getAuthorizationInstructions(authorization) !== null; + return hasUrl || hasInstructions; +} + +function CatalogEntryIcon({ entry }: { entry: PaseoAgentCatalogEntry }) { + if (hasKnownProviderIcon(entry.iconName)) { + return ( + + ); + } + return ; +} + +function ProviderRow({ + provider, + catalogEntry, + isFirst, + onReauth, +}: { + provider: RedactedPaseoAgentProviderConfig; + catalogEntry: PaseoAgentCatalogEntry | undefined; + isFirst: boolean; + onReauth: (provider: RedactedPaseoAgentProviderConfig, entry: PaseoAgentCatalogEntry) => void; +}) { + const modelLabel = modelCountLabel(provider.models.length); + const authBadge = paseoAgentAuthBadge(provider.auth); + const providerLabel = paseoAgentProviderLabel(provider, catalogEntry); + const canReauth = Boolean(catalogEntry && isPaseoAgentCatalogEntrySupported(catalogEntry)); + const accessibilityLabel = authBadge + ? `${provider.name}, ${providerLabel}, ${modelLabel}, ${authBadge.label}` + : `${provider.name}, ${providerLabel}, ${modelLabel}`; + const rowStyle = useMemo(() => [styles.providerRow, !isFirst && styles.rowBorder], [isFirst]); + + const handleReauth = useCallback(() => { + if (catalogEntry) { + onReauth(provider, catalogEntry); + } + }, [catalogEntry, onReauth, provider]); -function ProviderRow({ provider }: { provider: RedactedPaseoAgentProviderConfig }) { - const modelCount = provider.models.length; - const modelLabel = modelCount === 1 ? "1 model" : `${modelCount} models`; - const auth = paseoAgentAuthLabel(provider.auth); return ( @@ -46,143 +243,592 @@ function ProviderRow({ provider }: { provider: RedactedPaseoAgentProviderConfig {provider.name} - {provider.providerType} · {modelLabel} · {auth} + {providerLabel} · {modelLabel} + {authBadge ? : null} + {canReauth ? ( + + ) : null} ); } -function AddOpenRouterSubSheet({ - serverId, - visible, - onClose, +function CatalogEntryRow({ + entry, + isFirst, + onSelect, }: { - serverId: string; - visible: boolean; - onClose: () => void; + entry: PaseoAgentCatalogEntry; + isFirst: boolean; + onSelect: (entry: PaseoAgentCatalogEntry) => void; }) { - const { setProvider } = usePaseoAgentProviders(serverId); - const [name, setName] = useState(DEFAULT_PROVIDER_NAME); + const supported = isPaseoAgentCatalogEntrySupported(entry); + const rowStyle = useMemo( + () => [styles.catalogRow, !isFirst && styles.rowBorder, !supported && styles.disabledRow], + [isFirst, supported], + ); + const handleSelect = useCallback(() => onSelect(entry), [entry, onSelect]); + + return ( + + + + + + + {entry.label} + + + {entry.api} · {catalogModelSummary(entry)} + + {entry.docsUrl ? ( + + ) : null} + {!supported ? {APP_UPDATE_PROVIDER_MESSAGE} : null} + + + + ); +} + +function CatalogPickerSubSheet({ + visible, + catalog, + isLoading, + error, + onClose, + onSelect, +}: { + visible: boolean; + catalog: PaseoAgentCatalogEntry[]; + isLoading: boolean; + error: string | null; + onClose: () => void; + onSelect: (entry: PaseoAgentCatalogEntry) => void; +}) { + let body: React.ReactNode; + if (error) { + body = ( + + {error} + + ); + } else if (isLoading) { + body = ( + + Loading... + + ); + } else if (catalog.length === 0) { + body = ( + + No providers available. + + ); + } else { + body = ( + + {catalog.map((entry, index) => ( + + ))} + + ); + } + + return ( + + {body} + + ); +} + +function AuthorizationUrl({ url, label, testID }: { url: string; label: string; testID: string }) { + return ( + + + {url} + + + + ); +} + +function OAuthAuthorizationPanel({ + authorization, +}: { + authorization: PaseoAgentOAuthStartResult["authorization"]; +}) { + if (!authorization) { + return ( + + Finish sign in, then complete the flow. + + ); + } + + const instructions = getAuthorizationInstructions(authorization); + const url = getAuthorizationUrl(authorization); + + if (authorization.kind === "device_code") { + return ( + + {typeof authorization.userCode === "string" ? ( + <> + Device code + + {authorization.userCode} + + + ) : null} + {typeof authorization.verificationUri === "string" ? ( + + ) : null} + {instructions ? {instructions} : null} + {typeof authorization.expiresInSeconds === "number" ? ( + + Expires in about {Math.max(1, Math.round(authorization.expiresInSeconds / 60))} minutes. + + ) : null} + + ); + } + + if (authorization.kind === "auth_url") { + return ( + + {url ? ( + + ) : null} + {instructions ? {instructions} : null} + + ); + } + + return ( + + {url ? ( + + ) : null} + {instructions ? {instructions} : null} + {!url && !instructions ? Update the app : null} + + ); +} + +function ProviderNameAndModelsFields({ + entry, + name, + models, + resetKey, + lockName, + hasCatalogModels, + modelInputStyle, + onNameChange, + onModelsChange, +}: ProviderNameAndModelsFieldsProps) { + return ( + <> + Provider name + + {hasCatalogModels ? ( + {modelCountLabel(entry.models.length)} from the catalog + ) : ( + <> + Models + + One model id per line, or comma-separated. + + )} + + ); +} + +function ApiKeyFields({ auth, apiKey, resetKey, onApiKeyChange }: ApiKeyFieldsProps) { + const envHint = `Leave blank to use $${auth.envVar} on the host.`; + const apiKeyHint = auth.hint ?? "Stored on the host and never shown again."; + + return ( + <> + + API key + {auth.keyUrl ? ( + + ) : null} + + + + {apiKeyHint} {envHint} + + + ); +} + +function OAuthFields({ oauthState, activeAuthorization }: OAuthFieldsProps) { + return ( + <> + {activeAuthorization ? ( + + ) : ( + Sign in to connect this provider. + )} + {oauthState.status === "error" ? ( + + {oauthState.message} + + ) : null} + + ); +} + +function ProviderFormActions({ + saving, + canSubmit, + canComplete, + hasApiKeyAuth, + hasOAuthAuth, + oauthStatus, + onClose, + onSubmitApiKey, + onStartOAuth, + onCompleteOAuth, +}: ProviderFormActionsProps) { + const isOAuthIdle = oauthStatus === "idle"; + const isOAuthError = oauthStatus === "error"; + const isOAuthCompleting = oauthStatus === "completing"; + const showOAuthComplete = hasOAuthAuth && !isOAuthIdle && !isOAuthError; + + return ( + + + {hasApiKeyAuth ? ( + + ) : null} + {hasOAuthAuth && isOAuthIdle ? ( + + ) : null} + {hasOAuthAuth && isOAuthError ? ( + + ) : null} + {showOAuthComplete ? ( + + ) : null} + + ); +} + +function PaseoAgentProviderFormSheet({ + entry, + visible, + initialName, + lockName, + onBack, + onClose, + setProvider, + startOAuth, + completeOAuth, +}: { + entry: PaseoAgentCatalogEntry | null; + visible: boolean; + initialName: string | undefined; + lockName: boolean; + onBack: () => void; + onClose: () => void; + setProvider: ( + input: PaseoAgentSetProviderInput, + ) => Promise; + startOAuth: (name: string) => Promise; + completeOAuth: (name: string) => Promise; +}) { + const [name, setName] = useState(""); const [apiKey, setApiKey] = useState(""); const [models, setModels] = useState(""); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); + const [oauthState, setOAuthState] = useState({ status: "idle" }); const [resetKey, bumpResetKey] = useReducer((key: number) => key + 1, 0); + const entryId = entry?.id; + useEffect(() => { - if (!visible) { - setName(DEFAULT_PROVIDER_NAME); + if (visible && entry) { + setName(initialName ?? entry.id); setApiKey(""); setModels(""); setError(null); setSaving(false); + setOAuthState({ status: "idle" }); bumpResetKey(); } - }, [visible]); + }, [entry, entryId, initialName, visible]); + const header = useMemo( + () => ({ + title: entry ? entry.label : "Provider", + back: { label: "Providers", onPress: onBack }, + }), + [entry, onBack], + ); + const modelInputStyle = useMemo(() => [styles.formInput, styles.modelsInput], []); + + const apiKeyAuth = entry ? getPaseoAgentApiKeyAuth(entry) : null; + const oauthAuth = entry ? getPaseoAgentOAuthAuth(entry) : null; const trimmedName = name.trim(); const modelIds = useMemo(() => parsePaseoAgentModelIds(models), [models]); - const canSubmit = trimmedName.length > 0 && modelIds.length > 0 && !saving; + const hasCatalogModels = Boolean(entry && entry.models.length > 0); + const hasConfiguredModels = hasCatalogModels || modelIds.length > 0; + const canSubmit = Boolean(entry && trimmedName.length > 0 && hasConfiguredModels && !saving); + const activeAuthorization = + oauthState.status === "authorizing" || + oauthState.status === "completing" || + oauthState.status === "error" + ? oauthState.authorization + : null; + const canComplete = + oauthState.status === "authorizing" && canCompleteAuthorization(oauthState.authorization); - const handleSubmit = useCallback(() => { - if (!canSubmit) return; + const handleSubmitApiKey = useCallback(() => { + if (!entry || !canSubmit) return; setError(null); setSaving(true); - void setProvider(createOpenRouterProviderInput({ name: trimmedName, apiKey, modelIds })) + void setProvider( + createPaseoAgentProviderInput({ + entry, + name: trimmedName, + apiKey, + modelIds: hasCatalogModels ? undefined : modelIds, + }), + ) .then(() => { setApiKey(""); onClose(); return undefined; }) .catch((err: unknown) => { - setError(err instanceof Error ? err.message : "Failed to save provider"); + setError(describeError(err, "Failed to save provider")); }) .finally(() => setSaving(false)); - }, [apiKey, canSubmit, modelIds, onClose, setProvider, trimmedName]); + }, [apiKey, canSubmit, entry, hasCatalogModels, modelIds, onClose, setProvider, trimmedName]); + + const handleStartOAuth = useCallback(() => { + if (!entry || !canSubmit) return; + setError(null); + setSaving(true); + setOAuthState({ status: "idle" }); + void setProvider( + createPaseoAgentProviderInput({ + entry, + name: trimmedName, + modelIds: hasCatalogModels ? undefined : modelIds, + }), + ) + .then(() => startOAuth(trimmedName)) + .then((result) => { + setOAuthState({ status: "authorizing", authorization: result.authorization }); + return undefined; + }) + .catch((err: unknown) => { + setOAuthState({ + status: "error", + message: describeError(err, "Failed to start sign in"), + authorization: null, + }); + }) + .finally(() => setSaving(false)); + }, [canSubmit, entry, hasCatalogModels, modelIds, setProvider, startOAuth, trimmedName]); + + const handleCompleteOAuth = useCallback(() => { + if (!canSubmit || !canComplete) return; + setError(null); + setSaving(true); + setOAuthState({ status: "completing", authorization: activeAuthorization }); + void completeOAuth(trimmedName) + .then(() => { + onClose(); + return undefined; + }) + .catch((err: unknown) => { + setOAuthState({ + status: "error", + message: describeError(err, "Failed to complete sign in"), + authorization: activeAuthorization, + }); + }) + .finally(() => setSaving(false)); + }, [activeAuthorization, canComplete, canSubmit, completeOAuth, onClose, trimmedName]); + + if (!entry) { + return null; + } return ( - Provider name - - API key - - - Stored on the host and never shown again. Leave blank to use OPENROUTER_API_KEY on the - host. - + {apiKeyAuth ? ( + + ) : null} - Models - - One model id per line, or comma-separated. + {oauthAuth ? ( + + ) : null} + + {!apiKeyAuth && !oauthAuth ? ( + {APP_UPDATE_PROVIDER_MESSAGE} + ) : null} {error ? ( - + {error} ) : null} - - - - + ); @@ -193,36 +839,85 @@ export function PaseoAgentSettingsSheet({ visible, onClose, }: PaseoAgentSettingsSheetProps) { - const { supported, providers, isLoading, error } = usePaseoAgentProviders(serverId); - const [addOpen, setAddOpen] = useState(false); + const { + supported, + catalogSupported, + providers, + catalog, + isLoading, + isCatalogLoading, + error, + catalogError, + setProvider, + startOAuth, + completeOAuth, + } = usePaseoAgentProviders(serverId); + const [addSheet, setAddSheet] = useState({ kind: "closed" }); useEffect(() => { if (!visible) { - setAddOpen(false); + setAddSheet({ kind: "closed" }); } }, [visible]); - const handleOpenAdd = useCallback(() => setAddOpen(true), []); - const handleCloseAdd = useCallback(() => setAddOpen(false), []); + const catalogById = useMemo(() => { + const map = new Map(); + for (const entry of catalog) { + map.set(entry.id, entry); + } + return map; + }, [catalog]); + + const handleOpenPicker = useCallback(() => setAddSheet({ kind: "picker" }), []); + const handleCloseSubSheet = useCallback(() => setAddSheet({ kind: "closed" }), []); + const handleSelectEntry = useCallback((entry: PaseoAgentCatalogEntry) => { + setAddSheet({ kind: "form", entry, lockName: false, returnToPicker: true }); + }, []); + const handleReauth = useCallback( + (provider: RedactedPaseoAgentProviderConfig, entry: PaseoAgentCatalogEntry) => { + setAddSheet({ + kind: "form", + entry, + initialName: provider.name, + lockName: true, + returnToPicker: false, + }); + }, + [], + ); + const handleBackFromForm = useCallback(() => { + setAddSheet((current) => + current.kind === "form" && current.returnToPicker ? { kind: "picker" } : { kind: "closed" }, + ); + }, []); const footer = useMemo(() => { if (!supported) { return undefined; } + if (!catalogSupported) { + return ( + + + {CATALOG_UPDATE_MESSAGE} + + + ); + } return ( ); - }, [supported, handleOpenAdd]); + }, [catalogSupported, handleOpenPicker, supported]); let body: React.ReactNode; if (!supported) { @@ -240,20 +935,26 @@ export function PaseoAgentSettingsSheet({ } else if (isLoading) { body = ( - Loading… + Loading... ); } else if (providers.length === 0) { body = ( - No model providers configured yet. + No providers configured yet. ); } else { body = ( - {providers.map((provider) => ( - + {providers.map((provider, index) => ( + ))} ); @@ -271,8 +972,28 @@ export function PaseoAgentSettingsSheet({ > {body} - {supported ? ( - + {supported && catalogSupported ? ( + + ) : null} + {supported && catalogSupported ? ( + ) : null} ); @@ -285,14 +1006,16 @@ const styles = StyleSheet.create((theme) => ({ borderColor: theme.colors.border, overflow: "hidden", }, + rowBorder: { + borderTopWidth: 1, + borderTopColor: theme.colors.border, + }, providerRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[3], paddingVertical: theme.spacing[3], paddingHorizontal: theme.spacing[4], - borderTopWidth: 1, - borderTopColor: theme.colors.border, }, providerText: { flex: 1, @@ -302,7 +1025,7 @@ const styles = StyleSheet.create((theme) => ({ providerName: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, + fontWeight: theme.fontWeight.normal, }, providerMeta: { color: theme.colors.foregroundMuted, @@ -320,6 +1043,29 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: 4, backgroundColor: theme.colors.foregroundMuted, }, + catalogRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + }, + catalogIcon: { + width: 28, + height: 28, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + }, + catalogText: { + flex: 1, + minWidth: 0, + gap: theme.spacing[1], + }, + disabledRow: { + opacity: theme.opacity[50], + }, stateBox: { minHeight: 96, borderRadius: theme.borderRadius.lg, @@ -339,6 +1085,12 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, flexDirection: "row", justifyContent: "flex-end", + alignItems: "center", + }, + updateText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + textAlign: "right", }, formGroup: { gap: theme.spacing[2], @@ -349,6 +1101,12 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, marginTop: theme.spacing[2], }, + labelRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[3], + }, formHint: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, @@ -367,6 +1125,24 @@ const styles = StyleSheet.create((theme) => ({ minHeight: 80, textAlignVertical: "top", }, + authorizationBox: { + gap: theme.spacing[2], + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface1, + padding: theme.spacing[3], + marginTop: theme.spacing[2], + }, + userCode: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.medium, + letterSpacing: 0, + }, + linkStack: { + gap: theme.spacing[1], + }, errorText: { fontSize: theme.fontSize.xs, color: theme.colors.destructive, @@ -378,10 +1154,3 @@ const styles = StyleSheet.create((theme) => ({ marginTop: theme.spacing[3], }, })); - -const FORM_INPUT_STYLE = [styles.formInput, isWeb && { outlineStyle: "none" }]; -const MODELS_INPUT_STYLE = [ - styles.formInput, - styles.modelsInput, - isWeb && { outlineStyle: "none" }, -]; diff --git a/packages/app/src/hooks/use-paseo-agent-providers.ts b/packages/app/src/hooks/use-paseo-agent-providers.ts index 85ffbb9247..6294b59d10 100644 --- a/packages/app/src/hooks/use-paseo-agent-providers.ts +++ b/packages/app/src/hooks/use-paseo-agent-providers.ts @@ -2,6 +2,9 @@ import { useCallback, useMemo } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import type { + PaseoAgentCatalogEntry, + PaseoAgentOAuthCompleteResponse, + PaseoAgentOAuthStartResponse, PaseoAgentSetProviderRequest, RedactedPaseoAgentProviderConfig, } from "@getpaseo/protocol/messages"; @@ -12,6 +15,10 @@ export function paseoAgentProvidersQueryKey(serverId: string | null) { return ["paseo-agent-providers", serverId] as const; } +export function paseoAgentCatalogQueryKey(serverId: string | null) { + return ["paseo-agent-catalog", serverId] as const; +} + function describeQueryError(error: unknown): string | null { if (!error) { return null; @@ -20,17 +27,25 @@ function describeQueryError(error: unknown): string | null { } export type PaseoAgentSetProviderInput = Omit; +export type PaseoAgentOAuthStartResult = PaseoAgentOAuthStartResponse["payload"]; +export type PaseoAgentOAuthCompleteResult = PaseoAgentOAuthCompleteResponse["payload"]; interface UsePaseoAgentProvidersResult { supported: boolean; + catalogSupported: boolean; providers: RedactedPaseoAgentProviderConfig[]; + catalog: PaseoAgentCatalogEntry[]; defaultModel: string | null; isLoading: boolean; + isCatalogLoading: boolean; error: string | null; + catalogError: string | null; refresh: () => Promise; setProvider: ( input: PaseoAgentSetProviderInput, ) => Promise; + startOAuth: (name: string) => Promise; + completeOAuth: (name: string) => Promise; } export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentProvidersResult { @@ -44,7 +59,12 @@ export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentPr const supported = useSessionStore( (state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentConfig === true, ); + // COMPAT(paseoAgentCatalog): added in v0.1.104, drop the gate when floor >= v0.1.104. + const catalogSupported = useSessionStore( + (state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentCatalog === true, + ); const queryKey = useMemo(() => paseoAgentProvidersQueryKey(serverId), [serverId]); + const catalogQueryKey = useMemo(() => paseoAgentCatalogQueryKey(serverId), [serverId]); const query = useQuery({ queryKey, @@ -58,11 +78,25 @@ export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentPr }, }); + const catalogQuery = useQuery({ + queryKey: catalogQueryKey, + enabled: Boolean(supported && catalogSupported && serverId && client && isConnected), + staleTime: 30_000, + queryFn: async () => { + if (!client) { + throw new Error(hostDisconnectedMessage); + } + return client.getPaseoAgentCatalog(); + }, + }); + const refresh = useCallback(async () => { await queryClient.invalidateQueries({ queryKey }); - }, [queryClient, queryKey]); + await queryClient.invalidateQueries({ queryKey: catalogQueryKey }); + }, [catalogQueryKey, queryClient, queryKey]); const error = query.data?.error ?? describeQueryError(query.error); + const catalogError = catalogQuery.data?.error ?? describeQueryError(catalogQuery.error); const setProviderMutation = useMutation({ mutationFn: async (input: PaseoAgentSetProviderInput) => { @@ -86,13 +120,57 @@ export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentPr [setProviderAsync], ); + const startOAuthMutation = useMutation({ + mutationFn: async (name: string) => { + if (!client) { + throw new Error(hostDisconnectedMessage); + } + const result = await client.startPaseoAgentOAuth(name); + if (!result.success) { + throw new Error(result.error ?? saveProviderFailedMessage); + } + return result; + }, + }); + const { mutateAsync: startOAuthAsync } = startOAuthMutation; + + const startOAuth = useCallback((name: string) => startOAuthAsync(name), [startOAuthAsync]); + + const completeOAuthMutation = useMutation({ + mutationFn: async (name: string) => { + if (!client) { + throw new Error(hostDisconnectedMessage); + } + const result = await client.completePaseoAgentOAuth(name); + if (!result.success) { + throw new Error(result.error ?? saveProviderFailedMessage); + } + return result; + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey }); + }, + }); + const { mutateAsync: completeOAuthAsync } = completeOAuthMutation; + + const completeOAuth = useCallback( + (name: string) => completeOAuthAsync(name), + [completeOAuthAsync], + ); + return { supported, + catalogSupported, providers: query.data?.providers ?? [], + catalog: catalogQuery.data?.catalog ?? [], defaultModel: query.data?.defaultModel ?? null, isLoading: query.isLoading, + isCatalogLoading: catalogQuery.isLoading, error, + catalogError, refresh, setProvider, + startOAuth, + completeOAuth, }; }