diff --git a/packages/app/e2e/helpers/paseo-agent.ts b/packages/app/e2e/helpers/paseo-agent.ts index 05de2b2f24..f2bfdaf0d7 100644 --- a/packages/app/e2e/helpers/paseo-agent.ts +++ b/packages/app/e2e/helpers/paseo-agent.ts @@ -17,14 +17,11 @@ type PaseoAgentDaemonClient = Pick< interface ApiKeyProviderInput { catalogId: string; - name: string; apiKey: string; - models?: string[]; } interface OAuthProviderInput { catalogId: string; - name: string; } interface ExpectedProvider { @@ -54,11 +51,9 @@ export async function addApiKeyProvider(page: Page, provider: ApiKeyProviderInpu 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 expect(page.getByLabel("Provider name")).toHaveCount(0); + await expect(page.getByLabel("Models")).toHaveCount(0); 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.getByTestId("paseo-agent-provider-form")).toHaveCount(0); @@ -72,9 +67,13 @@ export async function startOAuthProviderSignIn( 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 expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0); + await expect(page.getByTestId("paseo-agent-oauth-sign-in")).toBeVisible(); + await expect(page.getByLabel("Provider name")).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Sign in with browser", exact: true }), + ).toBeVisible(); await page.getByRole("button", { name: "Use a code instead", exact: true }).click(); await expect(page.getByTestId("paseo-agent-oauth-user-code")).toBeVisible(); await expect(page.getByTestId("paseo-agent-oauth-verification-link")).toBeVisible(); diff --git a/packages/app/e2e/paseo-agent-provider-config.spec.ts b/packages/app/e2e/paseo-agent-provider-config.spec.ts index 04a9d83d93..cf86135f51 100644 --- a/packages/app/e2e/paseo-agent-provider-config.spec.ts +++ b/packages/app/e2e/paseo-agent-provider-config.spec.ts @@ -1,3 +1,4 @@ +import { expect } from "@playwright/test"; import { test } from "./fixtures"; import { addApiKeyProvider, @@ -8,8 +9,11 @@ import { startOAuthProviderSignIn, } from "./helpers/paseo-agent"; -const OPENROUTER_PROVIDER = "phase-e-openrouter-ui"; const CHATGPT_PROVIDER = "phase-e-chatgpt-ui"; +const CLOSE_PROVIDER = "phase-e-close-ui"; +const DEFAULT_OPENROUTER_PROVIDER = "OpenRouter"; +const DEFAULT_CHATGPT_PROVIDER = "ChatGPT"; +const RENAME_PROVIDER = "phase-e-rename-ui"; test.describe("Paseo Agent provider configuration", () => { const providerNamesToCleanup = new Set(); @@ -20,34 +24,82 @@ test.describe("Paseo Agent provider configuration", () => { }); test("adds an OpenRouter model provider from Settings", async ({ page }) => { - providerNamesToCleanup.add(OPENROUTER_PROVIDER); + providerNamesToCleanup.add(DEFAULT_OPENROUTER_PROVIDER); await openPaseoAgentSettings(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"], }); await expectModelProviderListed(page, { - name: OPENROUTER_PROVIDER, + name: DEFAULT_OPENROUTER_PROVIDER, providerLabel: "OpenRouter", - modelCount: 2, + modelCount: 0, + auth: "Connected", + }); + }); + + test("adds an API-key provider without prompting for a provider name", async ({ page }) => { + providerNamesToCleanup.add(DEFAULT_OPENROUTER_PROVIDER); + + await openPaseoAgentSettings(page); + await page.getByRole("button", { name: "Add model provider", exact: true }).click(); + await page.getByTestId("paseo-agent-catalog-select-openrouter").click(); + + await expect(page.getByTestId("paseo-agent-provider-form")).toBeVisible(); + await expect(page.getByLabel("Provider name")).toHaveCount(0); + await expect(page.getByLabel("Models")).toHaveCount(0); + await page.getByLabel("API key").fill("sk-or-default-name"); + await page.getByRole("button", { name: "Save provider", exact: true }).click(); + + await expectModelProviderListed(page, { + name: DEFAULT_OPENROUTER_PROVIDER, + providerLabel: "OpenRouter", + modelCount: 0, auth: "Connected", }); }); test("starts a ChatGPT sign-in from Settings", async ({ page }) => { - providerNamesToCleanup.add(CHATGPT_PROVIDER); + providerNamesToCleanup.add(DEFAULT_CHATGPT_PROVIDER); await openPaseoAgentSettings(page); await startOAuthProviderSignIn(page, { catalogId: "chatgpt", - name: CHATGPT_PROVIDER, }); }); + test("starts OAuth sign-in without prompting for a provider name", async ({ page }) => { + providerNamesToCleanup.add(DEFAULT_CHATGPT_PROVIDER); + + await openPaseoAgentSettings(page); + await page.getByRole("button", { name: "Add model provider", exact: true }).click(); + await page.getByTestId("paseo-agent-catalog-select-chatgpt").click(); + + await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0); + await expect(page.getByLabel("Provider name")).toHaveCount(0); + }); + + test("shows user-facing provider picker rows", async ({ page }) => { + await openPaseoAgentSettings(page); + await page.getByRole("button", { name: "Add model provider", exact: true }).click(); + + const picker = page.getByTestId("paseo-agent-provider-picker"); + await expect( + page + .getByTestId("paseo-agent-catalog-entry-openrouter") + .getByText("API key", { exact: true }), + ).toBeVisible(); + await expect(page.getByTestId("paseo-agent-catalog-icon-openrouter")).toBeVisible(); + await expect( + page.getByTestId("paseo-agent-catalog-entry-chatgpt").getByText("Sign in", { exact: true }), + ).toBeVisible(); + await expect(page.getByTestId("paseo-agent-catalog-icon-chatgpt")).toBeVisible(); + await expect(picker.getByText("openai-codex-responses")).toHaveCount(0); + await expect(picker.getByText(/\b\d+ models?\b/)).toHaveCount(0); + }); + test("shows a stored ChatGPT login as a read-only model provider row", async ({ page }) => { providerNamesToCleanup.add(CHATGPT_PROVIDER); @@ -61,4 +113,41 @@ test.describe("Paseo Agent provider configuration", () => { auth: "Connected", }); }); + + test("renames a configured provider row without changing its stored credential key", async ({ + page, + }) => { + providerNamesToCleanup.add(RENAME_PROVIDER); + + await seedChatGptProvider(RENAME_PROVIDER); + await openPaseoAgentSettings(page); + await page.getByTestId(`paseo-agent-provider-rename-${RENAME_PROVIDER}`).click(); + await expect(page.getByTestId("paseo-agent-provider-rename-form")).toBeVisible(); + await page.getByLabel("Provider name").fill("Work account"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + + await expectModelProviderListed(page, { + name: "Work account", + providerLabel: "ChatGPT", + modelCount: 1, + auth: "Connected", + }); + }); + + test("closes the Paseo Agent settings sheet after providers load", async ({ page }) => { + providerNamesToCleanup.add(CLOSE_PROVIDER); + + await seedChatGptProvider(CLOSE_PROVIDER); + await openPaseoAgentSettings(page); + await expectModelProviderListed(page, { + name: CLOSE_PROVIDER, + providerLabel: "ChatGPT", + modelCount: 1, + auth: "Connected", + }); + + await page.getByLabel("Close", { exact: true }).click(); + + await expect(page.getByTestId("paseo-agent-settings-sheet")).toHaveCount(0); + }); }); diff --git a/packages/app/src/components/icons/openrouter-icon.tsx b/packages/app/src/components/icons/openrouter-icon.tsx new file mode 100644 index 0000000000..c86249385c --- /dev/null +++ b/packages/app/src/components/icons/openrouter-icon.tsx @@ -0,0 +1,20 @@ +import Svg, { Path } from "react-native-svg"; + +interface OpenRouterIconProps { + size?: number; + color?: string; +} + +export function OpenRouterIcon({ size = 16, color = "currentColor" }: OpenRouterIconProps) { + return ( + + + + ); +} 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 15b08ed85e..c68ebb7066 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 @@ -9,6 +9,7 @@ import { getPaseoAgentApiKeyAuth, getPaseoAgentOAuthAuth, isPaseoAgentCatalogEntrySupported, + nextPaseoAgentProviderName, parsePaseoAgentModelIds, paseoAgentAuthBadge, paseoAgentProviderLabel, @@ -95,6 +96,21 @@ describe("paseo-agent-settings-sheet-model", () => { ).toEqual(["alpha/fast", "beta/steady", "gamma/deep"]); }); + it("uses the catalog label for the first provider instance", () => { + expect(nextPaseoAgentProviderName(catalogEntry({ label: "Acme Cloud" }), [])).toBe( + "Acme Cloud", + ); + }); + + it("numbers later provider instances from the catalog label", () => { + expect( + nextPaseoAgentProviderName(catalogEntry({ label: "Acme Cloud" }), [ + providerConfig({ name: "Acme Cloud" }), + providerConfig({ name: "Acme Cloud (1)" }), + ]), + ).toBe("Acme Cloud (2)"); + }); + it("builds a generic provider payload with a trimmed explicit key", () => { expect( createPaseoAgentProviderInput({ 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 575a02c351..21758d3a8d 100644 --- a/packages/app/src/components/paseo-agent-settings-sheet-model.ts +++ b/packages/app/src/components/paseo-agent-settings-sheet-model.ts @@ -93,6 +93,24 @@ export function parsePaseoAgentModelIds(raw: string): string[] { return ids; } +export function nextPaseoAgentProviderName( + entry: PaseoAgentCatalogEntry, + providers: RedactedPaseoAgentProviderConfig[], +): string { + const baseName = entry.label.trim() || entry.id; + const existingNames = new Set(providers.map((provider) => provider.name)); + if (!existingNames.has(baseName)) { + return baseName; + } + + for (let index = 1; ; index += 1) { + const candidate = `${baseName} (${index})`; + if (!existingNames.has(candidate)) { + return candidate; + } + } +} + export function createPaseoAgentProviderInput(input: { entry: PaseoAgentCatalogEntry; name: string; diff --git a/packages/app/src/components/paseo-agent-settings-sheet.test.tsx b/packages/app/src/components/paseo-agent-settings-sheet.test.tsx index 6e20c93628..a76b00c065 100644 --- a/packages/app/src/components/paseo-agent-settings-sheet.test.tsx +++ b/packages/app/src/components/paseo-agent-settings-sheet.test.tsx @@ -400,24 +400,19 @@ describe("PaseoAgentSettingsSheet", () => { 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" }, - }); + expect(screen.queryByLabelText("Provider name")).toBeNull(); + expect(screen.queryByLabelText("Models")).toBeNull(); 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", + name: "Catalog Alpha", providerType: "catalog-alpha", options: { apiKey: "alpha-secret", - models: [{ id: "alpha/fast" }, { id: "beta/steady" }], }, } satisfies PaseoAgentSetProviderInput); }); @@ -433,7 +428,7 @@ describe("PaseoAgentSettingsSheet", () => { await waitFor(() => { expect(hookState.current.setProvider).toHaveBeenCalledWith({ - name: "catalog-alpha", + name: "Catalog Alpha", providerType: "catalog-alpha", options: { apiKey: "$ALPHA_API_KEY", @@ -470,7 +465,7 @@ describe("PaseoAgentSettingsSheet", () => { await waitFor(() => { expect(openExternalUrls).toEqual(["https://login.example.test/oauth/authorize?state=abc"]); - expect(hookState.current.startOAuth).toHaveBeenCalledWith("catalog-login", "browser"); + expect(hookState.current.startOAuth).toHaveBeenCalledWith("Catalog Login", "browser"); }); expect(screen.getByText("https://login.example.test/oauth/authorize?state=abc")).toBeTruthy(); expect(screen.getByTestId("paseo-agent-oauth-url").getAttribute("href")).toBe( @@ -512,14 +507,14 @@ describe("PaseoAgentSettingsSheet", () => { await waitFor(() => { expect(hookState.current.setProvider).toHaveBeenCalledWith({ - name: "catalog-login", + name: "Catalog Login", providerType: "catalog-login", options: { models: [{ id: "alpha-fast", label: "Alpha Fast" }], }, } satisfies PaseoAgentSetProviderInput); - expect(hookState.current.startOAuth).toHaveBeenCalledWith("catalog-login", "device_code"); - expect(hookState.current.completeOAuth).toHaveBeenCalledWith("catalog-login"); + expect(hookState.current.startOAuth).toHaveBeenCalledWith("Catalog Login", "device_code"); + expect(hookState.current.completeOAuth).toHaveBeenCalledWith("Catalog Login"); }); }); @@ -540,7 +535,7 @@ describe("PaseoAgentSettingsSheet", () => { fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login")); const actionButtons = screen - .getByTestId("paseo-agent-provider-form") + .getByTestId("paseo-agent-oauth-sign-in") .querySelectorAll("button"); expect(Array.from(actionButtons).map((button) => button.textContent)).toEqual([ "Providers", diff --git a/packages/app/src/components/paseo-agent-settings-sheet.tsx b/packages/app/src/components/paseo-agent-settings-sheet.tsx index 044367fd6f..9bbee59a42 100644 --- a/packages/app/src/components/paseo-agent-settings-sheet.tsx +++ b/packages/app/src/components/paseo-agent-settings-sheet.tsx @@ -21,6 +21,7 @@ import { useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import type { PaseoAgentOAuthCompleteResult, PaseoAgentOAuthStartResult, + PaseoAgentRenameProviderInput, PaseoAgentSetProviderInput, } from "@/hooks/use-paseo-agent-providers"; import type { Theme } from "@/styles/theme"; @@ -32,7 +33,7 @@ import { getPaseoAgentApiKeyAuth, getPaseoAgentOAuthAuth, isPaseoAgentCatalogEntrySupported, - parsePaseoAgentModelIds, + nextPaseoAgentProviderName, paseoAgentAuthBadge, paseoAgentProviderLabel, preferredPaseoAgentOAuthMode, @@ -56,9 +57,12 @@ type AddSheetState = | { kind: "form"; entry: PaseoAgentCatalogEntry; - initialName?: string; - lockName: boolean; + name: string; returnToPicker: boolean; + } + | { + kind: "rename"; + provider: RedactedPaseoAgentProviderConfig; }; type OAuthState = @@ -80,17 +84,6 @@ type OAuthState = authorization: PaseoAgentOAuthStartResult["authorization"]; }; -interface ProviderNameAndModelsFieldsProps { - entry: PaseoAgentCatalogEntry; - name: string; - models: string; - resetKey: number; - lockName: boolean; - hasCatalogModels: boolean; - onNameChange: (value: string) => void; - onModelsChange: (value: string) => void; -} - interface ApiKeyFieldsProps { auth: PaseoAgentApiKeyAuthManifest; apiKey: string; @@ -146,8 +139,14 @@ 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 catalogAuthSummary(entry: PaseoAgentCatalogEntry): string | null { + if (getPaseoAgentOAuthAuth(entry)) { + return "Sign in"; + } + if (getPaseoAgentApiKeyAuth(entry)) { + return "API key"; + } + return null; } function hasKnownProviderIcon(iconName: string | undefined): iconName is string { @@ -205,19 +204,22 @@ function ProviderRow({ catalogEntry, isFirst, onReauth, + onRename, }: { provider: RedactedPaseoAgentProviderConfig; catalogEntry: PaseoAgentCatalogEntry | undefined; isFirst: boolean; onReauth: (provider: RedactedPaseoAgentProviderConfig, entry: PaseoAgentCatalogEntry) => void; + onRename: (provider: RedactedPaseoAgentProviderConfig) => void; }) { const modelLabel = modelCountLabel(provider.models.length); const authBadge = paseoAgentAuthBadge(provider.auth); const providerLabel = paseoAgentProviderLabel(provider, catalogEntry); + const providerName = provider.displayName ?? provider.name; const canReauth = Boolean(catalogEntry && isPaseoAgentCatalogEntrySupported(catalogEntry)); const accessibilityLabel = authBadge - ? `${provider.name}, ${providerLabel}, ${modelLabel}, ${authBadge.label}` - : `${provider.name}, ${providerLabel}, ${modelLabel}`; + ? `${providerName}, ${providerLabel}, ${modelLabel}, ${authBadge.label}` + : `${providerName}, ${providerLabel}, ${modelLabel}`; const rowStyle = useMemo(() => [styles.providerRow, !isFirst && styles.rowBorder], [isFirst]); const handleReauth = useCallback(() => { @@ -225,6 +227,9 @@ function ProviderRow({ onReauth(provider, catalogEntry); } }, [catalogEntry, onReauth, provider]); + const handleRename = useCallback(() => { + onRename(provider); + }, [onRename, provider]); return ( - {provider.name} + {providerName} {providerLabel} · {modelLabel} {authBadge ? : null} + {canReauth ? ( + + + + + ); +} + export function PaseoAgentSettingsSheet({ serverId, visible, @@ -890,6 +928,7 @@ export function PaseoAgentSettingsSheet({ setProvider, startOAuth, completeOAuth, + renameProvider, } = usePaseoAgentProviders(serverId); const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null; const preferredOAuthMode = preferredPaseoAgentOAuthMode(activeConnection); @@ -911,21 +950,31 @@ export function PaseoAgentSettingsSheet({ 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 handleSelectEntry = useCallback( + (entry: PaseoAgentCatalogEntry) => { + setAddSheet({ + kind: "form", + entry, + name: nextPaseoAgentProviderName(entry, providers), + returnToPicker: true, + }); + }, + [providers], + ); const handleReauth = useCallback( (provider: RedactedPaseoAgentProviderConfig, entry: PaseoAgentCatalogEntry) => { setAddSheet({ kind: "form", entry, - initialName: provider.name, - lockName: true, + name: provider.name, returnToPicker: false, }); }, [], ); + const handleRename = useCallback((provider: RedactedPaseoAgentProviderConfig) => { + setAddSheet({ kind: "rename", provider }); + }, []); const handleBackFromForm = useCallback(() => { setAddSheet((current) => current.kind === "form" && current.returnToPicker ? { kind: "picker" } : { kind: "closed" }, @@ -983,6 +1032,7 @@ export function PaseoAgentSettingsSheet({ catalogEntry={catalogById.get(provider.providerType)} isFirst={index === 0} onReauth={handleReauth} + onRename={handleRename} /> ))} @@ -1015,8 +1065,7 @@ export function PaseoAgentSettingsSheet({ ) : null} + {supported && catalogSupported ? ( + + ) : null} ); } @@ -1151,10 +1208,6 @@ const styles = StyleSheet.create((theme) => ({ borderColor: theme.colors.border, fontSize: theme.fontSize.sm, }, - modelsInput: { - minHeight: 80, - textAlignVertical: "top", - }, authorizationBox: { gap: theme.spacing[2], borderRadius: theme.borderRadius.lg, diff --git a/packages/app/src/components/provider-icon-name.test.ts b/packages/app/src/components/provider-icon-name.test.ts index 0bc93e6664..cb876546cd 100644 --- a/packages/app/src/components/provider-icon-name.test.ts +++ b/packages/app/src/components/provider-icon-name.test.ts @@ -16,6 +16,16 @@ describe("resolveProviderIconName", () => { expect(resolveProviderIconName("paseo")).toEqual({ kind: "builtin", id: "paseo" }); }); + it("resolves Paseo Agent model-provider catalog icon names", () => { + expect(resolveProviderIconName("openai")).toEqual({ kind: "builtin", id: "openai" }); + expect(resolveProviderIconName("openrouter")).toEqual({ + kind: "builtin", + id: "openrouter", + }); + expect(resolveProviderIconName("kimi")).toEqual({ kind: "catalog", id: "kimi" }); + expect(resolveProviderIconName("opencode")).toEqual({ kind: "builtin", id: "opencode" }); + }); + it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => { expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" }); expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" }); diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index 66448ab254..22e2485281 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -6,6 +6,7 @@ import { CodexIcon } from "@/components/icons/codex-icon"; import { CopilotIcon } from "@/components/icons/copilot-icon"; import { MiniMaxIcon } from "@/components/icons/minimax-icon"; import { OpenCodeIcon } from "@/components/icons/opencode-icon"; +import { OpenRouterIcon } from "@/components/icons/openrouter-icon"; import { OmpIcon } from "@/components/icons/omp-icon"; import { PaseoLogo } from "@/components/icons/paseo-logo"; import { PiIcon } from "@/components/icons/pi-icon"; @@ -26,7 +27,9 @@ const BUILTIN_PROVIDER_ICONS: Record = { kiro: PackagePlus, minimax: MiniMaxIcon as unknown as ProviderIconComponent, omp: OmpIcon as unknown as ProviderIconComponent, + openai: CodexIcon as unknown as ProviderIconComponent, opencode: OpenCodeIcon as unknown as ProviderIconComponent, + openrouter: OpenRouterIcon as unknown as ProviderIconComponent, paseo: PaseoLogo as unknown as ProviderIconComponent, pi: PiIcon as unknown as ProviderIconComponent, }; diff --git a/packages/app/src/components/provider-settings-host.tsx b/packages/app/src/components/provider-settings-host.tsx index 80df3cc937..c21101433c 100644 --- a/packages/app/src/components/provider-settings-host.tsx +++ b/packages/app/src/components/provider-settings-host.tsx @@ -20,7 +20,7 @@ export function ProviderSettingsHost() { } if (provider === PASEO_AGENT_PROVIDER) { - return ; + return ; } return ( diff --git a/packages/app/src/hooks/use-paseo-agent-providers.ts b/packages/app/src/hooks/use-paseo-agent-providers.ts index 55a7d06f83..f53aa2358f 100644 --- a/packages/app/src/hooks/use-paseo-agent-providers.ts +++ b/packages/app/src/hooks/use-paseo-agent-providers.ts @@ -5,6 +5,7 @@ import type { PaseoAgentCatalogEntry, PaseoAgentOAuthCompleteResponse, PaseoAgentOAuthStartResponse, + PaseoAgentRenameProviderRequest, PaseoAgentSetProviderRequest, RedactedPaseoAgentProviderConfig, } from "@getpaseo/protocol/messages"; @@ -27,6 +28,10 @@ function describeQueryError(error: unknown): string | null { } export type PaseoAgentSetProviderInput = Omit; +export type PaseoAgentRenameProviderInput = Omit< + PaseoAgentRenameProviderRequest, + "type" | "requestId" +>; export type PaseoAgentOAuthStartResult = PaseoAgentOAuthStartResponse["payload"]; export type PaseoAgentOAuthCompleteResult = PaseoAgentOAuthCompleteResponse["payload"]; @@ -44,6 +49,9 @@ interface UsePaseoAgentProvidersResult { setProvider: ( input: PaseoAgentSetProviderInput, ) => Promise; + renameProvider: ( + input: PaseoAgentRenameProviderInput, + ) => Promise; startOAuth: (name: string, mode?: string) => Promise; completeOAuth: (name: string) => Promise; } @@ -120,6 +128,28 @@ export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentPr [setProviderAsync], ); + const renameProviderMutation = useMutation({ + mutationFn: async (input: PaseoAgentRenameProviderInput) => { + if (!client) { + throw new Error(hostDisconnectedMessage); + } + const result = await client.renamePaseoAgentProvider(input); + if (!result.success) { + throw new Error(result.error ?? saveProviderFailedMessage); + } + return result.provider; + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey }); + }, + }); + const { mutateAsync: renameProviderAsync } = renameProviderMutation; + + const renameProvider = useCallback( + (input: PaseoAgentRenameProviderInput) => renameProviderAsync(input), + [renameProviderAsync], + ); + const startOAuthMutation = useMutation({ mutationFn: async (input: { name: string; mode?: string }) => { if (!client) { @@ -173,6 +203,7 @@ export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentPr catalogError, refresh, setProvider, + renameProvider, startOAuth, completeOAuth, }; diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index f32c0c44e5..3c36836301 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -103,6 +103,8 @@ import type { PaseoAgentOAuthCredential, PaseoAgentOAuthStartResponse, PaseoAgentOAuthStoreCredentialResponse, + PaseoAgentRenameProviderRequest, + PaseoAgentRenameProviderResponse, PaseoAgentRemoveProviderResponse, PaseoAgentSetProviderRequest, PaseoAgentSetProviderResponse, @@ -3905,6 +3907,7 @@ export class DaemonClient { type: "config.paseo_agent.set_provider.request", name: input.name, providerType: input.providerType, + ...(input.displayName ? { displayName: input.displayName } : {}), options: input.options, }, timeout: 30000, @@ -3925,6 +3928,20 @@ export class DaemonClient { }); } + async renamePaseoAgentProvider( + input: Omit & { requestId?: string }, + ): Promise { + return this.sendNamespacedCorrelatedSessionRequest({ + requestId: input.requestId, + message: { + type: "config.paseo_agent.rename_provider.request", + name: input.name, + displayName: input.displayName, + }, + timeout: 30000, + }); + } + async startPaseoAgentOAuth( name: string, options?: string | { mode?: string; requestId?: string }, diff --git a/packages/protocol/src/messages.paseo-agent-config.test.ts b/packages/protocol/src/messages.paseo-agent-config.test.ts index 166594c5bc..5ffa18f903 100644 --- a/packages/protocol/src/messages.paseo-agent-config.test.ts +++ b/packages/protocol/src/messages.paseo-agent-config.test.ts @@ -228,6 +228,35 @@ describe("Paseo Agent config RPC schemas", () => { expect(JSON.stringify(parsed)).not.toContain("refresh-token"); }); + test("parses provider rename request and response with display name", () => { + const request = SessionInboundMessageSchema.parse({ + type: "config.paseo_agent.rename_provider.request", + requestId: "req-rename-provider", + name: "subscription", + displayName: "Work account", + }); + const response = SessionOutboundMessageSchema.parse({ + type: "config.paseo_agent.rename_provider.response", + payload: { + requestId: "req-rename-provider", + success: true, + provider: { + name: "subscription", + displayName: "Work account", + providerType: "chatgpt", + models: [{ id: "gpt-5.4-mini" }], + auth: { kind: "oauth", configured: true, source: "stored" }, + available: true, + error: null, + }, + error: null, + }, + }); + + expect(request.displayName).toBe("Work account"); + expect(response.payload.provider?.displayName).toBe("Work account"); + }); + test("parses ChatGPT provider config separately from credential storage", () => { const parsed = SessionInboundMessageSchema.parse({ type: "config.paseo_agent.set_provider.request", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 3abb37689f..53c2820559 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2004,6 +2004,7 @@ export const PaseoAgentProviderAuthStateSchema = z export const RedactedPaseoAgentProviderConfigSchema = z .object({ name: z.string().min(1), + displayName: z.string().min(1).optional(), providerType: PaseoAgentProviderTypeSchema, baseUrl: z.string().optional(), api: z.string().optional(), @@ -2028,6 +2029,7 @@ export const PaseoAgentSetProviderRequestSchema = z.object({ type: z.literal("config.paseo_agent.set_provider.request"), requestId: z.string(), name: z.string().trim().min(1), + displayName: z.string().trim().min(1).optional(), providerType: PaseoAgentProviderTypeSchema, options: PaseoAgentSetProviderOptionsSchema, }); @@ -2038,6 +2040,13 @@ export const PaseoAgentRemoveProviderRequestSchema = z.object({ name: z.string().trim().min(1), }); +export const PaseoAgentRenameProviderRequestSchema = z.object({ + type: z.literal("config.paseo_agent.rename_provider.request"), + requestId: z.string(), + name: z.string().trim().min(1), + displayName: z.string().trim().min(1), +}); + export const PaseoAgentOAuthStartRequestSchema = z.object({ type: z.literal("config.paseo_agent.oauth.start.request"), requestId: z.string(), @@ -2213,6 +2222,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ PaseoAgentGetCatalogRequestSchema, PaseoAgentSetProviderRequestSchema, PaseoAgentRemoveProviderRequestSchema, + PaseoAgentRenameProviderRequestSchema, PaseoAgentOAuthStartRequestSchema, PaseoAgentOAuthCompleteRequestSchema, PaseoAgentOAuthStoreCredentialRequestSchema, @@ -4056,6 +4066,16 @@ export const PaseoAgentRemoveProviderResponseSchema = z.object({ }), }); +export const PaseoAgentRenameProviderResponseSchema = z.object({ + type: z.literal("config.paseo_agent.rename_provider.response"), + payload: z.object({ + requestId: z.string(), + success: z.boolean(), + provider: RedactedPaseoAgentProviderConfigSchema.nullable(), + error: z.string().nullable(), + }), +}); + const PaseoAgentOAuthStartAuthorizationSchema = z .object({ kind: z.string().min(1), @@ -4500,6 +4520,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ PaseoAgentGetCatalogResponseSchema, PaseoAgentSetProviderResponseSchema, PaseoAgentRemoveProviderResponseSchema, + PaseoAgentRenameProviderResponseSchema, PaseoAgentOAuthStartResponseSchema, PaseoAgentOAuthCompleteResponseSchema, PaseoAgentOAuthStoreCredentialResponseSchema, @@ -4647,6 +4668,9 @@ export type PaseoAgentSetProviderResponse = z.infer; +export type PaseoAgentRenameProviderResponse = z.infer< + typeof PaseoAgentRenameProviderResponseSchema +>; export type PaseoAgentOAuthStartResponse = z.infer; export type PaseoAgentOAuthCompleteResponse = z.infer; export type PaseoAgentOAuthStoreCredentialResponse = z.infer< @@ -4732,6 +4756,7 @@ export type PaseoAgentGetProvidersRequest = z.infer; export type PaseoAgentSetProviderRequest = z.infer; export type PaseoAgentRemoveProviderRequest = z.infer; +export type PaseoAgentRenameProviderRequest = z.infer; export type PaseoAgentOAuthStartRequest = z.infer; export type PaseoAgentOAuthCompleteRequest = z.infer; export type PaseoAgentOAuthStoreCredentialRequest = z.infer< diff --git a/packages/protocol/src/provider-icon-names.ts b/packages/protocol/src/provider-icon-names.ts index a6685cd1b3..56fcdc89b1 100644 --- a/packages/protocol/src/provider-icon-names.ts +++ b/packages/protocol/src/provider-icon-names.ts @@ -5,7 +5,9 @@ export const BUILTIN_PROVIDER_ICON_NAMES = [ "kiro", "minimax", "omp", + "openai", "opencode", + "openrouter", "paseo", "pi", ]; diff --git a/packages/server/src/server/agent/providers/paseo-agent/catalog.ts b/packages/server/src/server/agent/providers/paseo-agent/catalog.ts index 794f93a528..06a88f3e97 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/catalog.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/catalog.ts @@ -30,6 +30,7 @@ export const PASEO_AGENT_PROVIDER_CATALOG = [ id: "openrouter", piProvider: "openrouter", label: "OpenRouter", + iconName: "openrouter", auth: { kind: "api_key", envVar: "OPENROUTER_API_KEY" }, defaultModels: false, }, @@ -43,12 +44,14 @@ export const PASEO_AGENT_PROVIDER_CATALOG = [ id: "kimi", piProvider: "kimi-coding", label: "Kimi Coding Plan", + iconName: "kimi", auth: { kind: "api_key", envVar: "KIMI_API_KEY" }, }, { id: "opencode-go", piProvider: "opencode-go", label: "OpenCode Go", + iconName: "opencode", auth: { kind: "api_key", envVar: "OPENCODE_API_KEY" }, }, ] as const satisfies readonly PaseoAgentCatalogRef[]; diff --git a/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts b/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts index c2510295b8..57f4fcd953 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts @@ -321,6 +321,41 @@ describe("PaseoAgentConfigService", () => { expect(JSON.stringify(providers)).not.toContain("refresh-token"); }); + test("renames the provider display name without moving stored OAuth credentials", () => { + const service = new PaseoAgentConfigService({ + paseoHome: home, + logger: createTestLogger(), + }); + service.setProvider({ + name: "subscription", + providerType: "chatgpt", + options: {}, + }); + service.storeOAuthCredential("subscription", { + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: 123, + }); + + const provider = service.renameProvider("subscription", "Work account"); + + expect(provider).toMatchObject({ + name: "subscription", + displayName: "Work account", + auth: { kind: "oauth", configured: true, source: "stored" }, + available: true, + }); + const stored = JSON.parse( + readFileSync(paseoAgentAuthStoragePath({ PASEO_HOME: home }), "utf8"), + ); + expect(Object.keys(stored)).toEqual(["subscription"]); + expect(stored.subscription).toMatchObject({ + type: "oauth", + refresh: "refresh-token", + }); + }); + test("reports OAuth as missing or needing attention for absent and mismatched credentials", () => { const service = new PaseoAgentConfigService({ paseoHome: home, diff --git a/packages/server/src/server/agent/providers/paseo-agent/config-service.ts b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts index e991b886c1..9f274e2e20 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config-service.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts @@ -40,6 +40,7 @@ interface PaseoAgentConfigServiceOptions { interface SetProviderInput { name: string; providerType: string; + displayName?: string; options: { apiKey?: string; baseUrl?: string; @@ -170,6 +171,9 @@ function redactedProviders( available: auth.configured, error: null, }; + if (entry.displayName) { + provider.displayName = entry.displayName; + } provider.baseUrl = settings.baseUrl; provider.api = settings.api; return provider; @@ -223,6 +227,9 @@ export class PaseoAgentConfigService { ...current.providers, [input.name]: { type: catalogEntry.id, + ...((input.displayName ?? current.providers?.[input.name]?.displayName) + ? { displayName: input.displayName ?? current.providers?.[input.name]?.displayName } + : {}), options: providerOptionsForPersist(input.options, catalogEntry), }, }, @@ -231,6 +238,27 @@ export class PaseoAgentConfigService { return this.requireRedactedProvider(next, input.name); } + renameProvider(name: string, displayName: string): RedactedPaseoAgentProviderConfig { + const trimmedDisplayName = displayName.trim(); + const next = this.updateConfig((current) => { + const provider = current.providers?.[name]; + if (!provider) { + throw new Error(`Paseo Agent provider '${name}' is not configured.`); + } + return PaseoAgentConfigSchema.parse({ + ...current, + providers: { + ...current.providers, + [name]: { + ...provider, + displayName: trimmedDisplayName, + }, + }, + }); + }); + return this.requireRedactedProvider(next, name); + } + removeProvider(name: string): boolean { let removed = false; this.updateConfig((current) => { diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.test.ts b/packages/server/src/server/agent/providers/paseo-agent/config.test.ts index 43ec95f170..6c192ccdc4 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.test.ts @@ -154,6 +154,21 @@ describe("resolvePaseoAgentCatalogAuth", () => { restoreEnvVars(previousValues); } }); + + it("assigns icon names to every Paseo Agent catalog entry", () => { + const iconNames = new Map( + PASEO_AGENT_PROVIDER_CATALOG.map((entry) => [entry.id, entry.iconName]), + ); + + expect(iconNames).toEqual( + new Map([ + ["openrouter", "openrouter"], + ["chatgpt", "openai"], + ["kimi", "kimi"], + ["opencode-go", "opencode"], + ]), + ); + }); }); describe("listPaseoAgentModels", () => { diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.ts b/packages/server/src/server/agent/providers/paseo-agent/config.ts index 45b4dc7844..ab378a5c96 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.ts @@ -45,6 +45,7 @@ const PaseoAgentProviderOptionsSchema = z const PaseoAgentModelProviderSchema = z .object({ type: z.string().min(1), + displayName: z.string().min(1).optional(), options: PaseoAgentProviderOptionsSchema.default({}), }) .strict(); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 5204a032d4..e2f46d5e90 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1808,6 +1808,8 @@ export class Session { return this.handlePaseoAgentSetProviderRequest(msg); case "config.paseo_agent.remove_provider.request": return this.handlePaseoAgentRemoveProviderRequest(msg); + case "config.paseo_agent.rename_provider.request": + return this.handlePaseoAgentRenameProviderRequest(msg); case "config.paseo_agent.oauth.start.request": return this.handlePaseoAgentOAuthStartRequest(msg); case "config.paseo_agent.oauth.complete.request": @@ -1931,6 +1933,7 @@ export class Session { try { const provider = this.createPaseoAgentConfigService().setProvider({ name: msg.name, + displayName: msg.displayName, providerType: msg.providerType, options: msg.options, }); @@ -1993,6 +1996,41 @@ export class Session { } } + private async handlePaseoAgentRenameProviderRequest( + msg: Extract, + ): Promise { + try { + const provider = this.createPaseoAgentConfigService().renameProvider( + msg.name, + msg.displayName, + ); + await this.refreshPaseoAgentRuntimeSnapshot(); + this.emit({ + type: "config.paseo_agent.rename_provider.response", + payload: { + requestId: msg.requestId, + success: true, + provider, + error: null, + }, + }); + } catch (error) { + this.sessionLogger.error( + { err: error, providerName: msg.name }, + "Failed to rename Paseo Agent provider", + ); + this.emit({ + type: "config.paseo_agent.rename_provider.response", + payload: { + requestId: msg.requestId, + success: false, + provider: null, + error: getErrorMessage(error), + }, + }); + } + } + private async handlePaseoAgentOAuthStartRequest( msg: Extract, ): Promise {