diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 55170f5669c..3a061853699 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -1439,6 +1439,15 @@ export const dict = { "settings.providers.section.connected": "Connected providers", "settings.providers.connected.empty": "No connected providers", "settings.providers.connected.environmentDescription": "Connected from your environment variables", + "settings.providers.account.manage": "Manage {{provider}} accounts", + "settings.providers.account.group": "Accounts", + "settings.providers.account.add": "Add account", + "settings.providers.account.remove": "Remove account…", + "settings.providers.account.active": "Active", + "settings.providers.account.switched.title": "{{provider}} account switched", + "settings.providers.account.switched.description": "Now using {{account}}.", + "settings.providers.account.removed.title": "{{account}} removed", + "settings.providers.account.removed.description": "{{provider}} will no longer use this account.", "settings.providers.console.available.one": "{{count}} provider available", "settings.providers.console.available.other": "{{count}} providers available", "settings.providers.section.popular": "Popular providers", diff --git a/packages/app/src/settings/providers/accounts.test.ts b/packages/app/src/settings/providers/accounts.test.ts new file mode 100644 index 00000000000..88c7998e22d --- /dev/null +++ b/packages/app/src/settings/providers/accounts.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test" +import type { IntegrationInfo } from "@opencode/client/promise" +import { activeProviderAccount, providerAccounts } from "./accounts" + +const integration = (connections: IntegrationInfo["connections"]): IntegrationInfo => ({ + id: "openai", + name: "OpenAI", + methods: [], + connections, +}) + +describe("provider accounts", () => { + test("preserves the server's active-first credential order", () => { + const value = integration([ + { type: "credential", id: "cred_work", label: "Work", method: "key" }, + { type: "env", name: "OPENAI_API_KEY" }, + { type: "credential", id: "cred_personal", label: "Personal", method: "oauth" }, + ]) + + expect(providerAccounts(value)).toEqual([ + { type: "credential", id: "cred_work", label: "Work", method: "key" }, + { type: "credential", id: "cred_personal", label: "Personal", method: "oauth" }, + ]) + expect(activeProviderAccount(value)).toEqual({ type: "credential", id: "cred_work", label: "Work", method: "key" }) + }) + + test("returns no active account for environment-only integrations", () => { + const value = integration([{ type: "env", name: "OPENAI_API_KEY" }]) + + expect(providerAccounts(value)).toEqual([]) + expect(activeProviderAccount(value)).toBeUndefined() + expect(providerAccounts(undefined)).toEqual([]) + }) +}) diff --git a/packages/app/src/settings/providers/accounts.ts b/packages/app/src/settings/providers/accounts.ts new file mode 100644 index 00000000000..d3abd849f9f --- /dev/null +++ b/packages/app/src/settings/providers/accounts.ts @@ -0,0 +1,11 @@ +import type { ConnectionInfo, IntegrationInfo } from "@opencode/client/promise" + +export type ProviderAccount = Extract + +export function providerAccounts(integration: IntegrationInfo | undefined) { + return integration?.connections.filter((connection): connection is ProviderAccount => connection.type === "credential") ?? [] +} + +export function activeProviderAccount(integration: IntegrationInfo | undefined) { + return providerAccounts(integration)[0] +} diff --git a/packages/app/src/settings/providers/providers.tsx b/packages/app/src/settings/providers/providers.tsx index 9b6f76ac98e..697d3628ff7 100644 --- a/packages/app/src/settings/providers/providers.tsx +++ b/packages/app/src/settings/providers/providers.tsx @@ -2,6 +2,7 @@ import { Button } from "@opencode/ui/button" import { Badge } from "@opencode/ui/badge" import { useDialog } from "@opencode/ui/context/dialog" import { Icon } from "@opencode/ui/icon" +import { Menu } from "@opencode/ui/menu" import { OpenCodeLogo } from "@/providers/opencode-logo" import { showToast } from "@/shell/notifications/toast" import { popularProviders, useProviders } from "@/providers/catalog/providers" @@ -16,6 +17,7 @@ import { CONSOLE_INTEGRATION, CONSOLE_PROVIDERS } from "@/providers/connect/cont import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog" import { ProviderModelIcon } from "@/providers/models/provider-group" import { SettingsList } from "@/settings/list" +import { activeProviderAccount, providerAccounts, type ProviderAccount } from "./accounts" import "@/settings/settings.css" type ProviderSource = "env" | "api" | "account" | "config" | "custom" @@ -47,6 +49,7 @@ export const SettingsProviders: Component<{ disconnecting: {} as Record, consoleExpanded: false, connecting: false, + credentialID: undefined as string | undefined, }) const updateDisconnecting = (ids: string[], status: "removing" | "removed" | "absent" | undefined) => setState("disconnecting", (current) => ({ @@ -190,6 +193,8 @@ export const SettingsProviders: Component<{ return currentSource !== "env" && currentSource !== "config" } + const canManageAccounts = (item: ProviderItem) => providerAccounts(integration(item)).length > 0 + const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const disconnect = async (item: ProviderItem, name: string) => { @@ -230,6 +235,132 @@ export const SettingsProviders: Component<{ }) } + const refreshAccounts = async () => { + const location = props.directory ? { directory: props.directory } : undefined + data.location.integration.invalidate(location) + data.location.provider.invalidate(location) + data.location.model.invalidate(location) + await Promise.all([ + data.location.integration.sync(location), + data.location.provider.sync(location), + data.location.model.sync(location), + ]) + } + + const accountError = (error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + showToast({ title: language.t("common.requestFailed"), description: message }) + } + + const activate = async (provider: ProviderItem, providerName: string, account: ProviderAccount) => { + if (activeProviderAccount(integration(provider))?.id === account.id) return + setState("credentialID", account.id) + await serverSdk.api.credential + .activate({ credentialID: account.id }) + .then(refreshAccounts) + .then(() => + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("settings.providers.account.switched.title", { provider: providerName }), + description: language.t("settings.providers.account.switched.description", { account: account.label }), + }), + ) + .catch(accountError) + .finally(() => setState("credentialID", undefined)) + } + + const remove = async (provider: ProviderItem, providerName: string, account: ProviderAccount) => { + const final = providerAccounts(integration(provider)).length === 1 + setState("credentialID", account.id) + await serverSdk.api.credential + .remove({ credentialID: account.id }) + .then(refreshAccounts) + .then(() => + showToast({ + variant: "success", + icon: "circle-check", + title: language.t( + final ? "provider.disconnect.toast.disconnected.title" : "settings.providers.account.removed.title", + final ? { provider: providerName } : { account: account.label }, + ), + description: language.t( + final + ? "provider.disconnect.toast.disconnected.description" + : "settings.providers.account.removed.description", + { provider: providerName }, + ), + }), + ) + .catch(accountError) + .finally(() => setState("credentialID", undefined)) + } + + function AccountMenu(menuProps: { provider: ProviderItem; name?: string }) { + const accounts = () => providerAccounts(integration(menuProps.provider)) + const active = () => activeProviderAccount(integration(menuProps.provider)) + const name = () => menuProps.name ?? menuProps.provider.name + + return ( + + + + + + + ) + } + return ( <>
@@ -268,22 +399,27 @@ export const SettingsProviders: Component<{
- {language.t("settings.providers.connected.environmentDescription")} - + + {language.t("settings.providers.connected.environmentDescription")} + + } + > + + } > - + } @@ -326,13 +462,20 @@ export const SettingsProviders: Component<{ - + } > - {language.t("common.disconnect")} - + +
diff --git a/packages/app/src/settings/settings.css b/packages/app/src/settings/settings.css index 092f7d7b155..fd6c76a69d4 100644 --- a/packages/app/src/settings/settings.css +++ b/packages/app/src/settings/settings.css @@ -887,6 +887,47 @@ opacity: 1; } +.settings-provider-account-trigger { + min-width: 0; + max-width: min(240px, 45%); +} + +.settings-provider-account-trigger > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-provider-account-menu[data-component="menu-v2-content"] { + width: min(260px, calc(100vw - 32px)); + overflow: visible; +} + +.settings-provider-account-list { + max-height: min(240px, calc(var(--kb-popper-content-available-height) - 120px)); + overflow-y: auto; +} + +.settings-provider-account-submenu[data-component="menu-v2-content"] { + width: min(260px, calc(100vw - 32px)); + max-height: min(360px, var(--kb-popper-content-available-height)); + overflow-y: auto; +} + +.settings-provider-account-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@container settings-panel (max-width: 520px) { + .settings-provider-account-trigger { + max-width: 100%; + } +} + .settings-providers-view-all { margin-top: 20px; padding: 0; diff --git a/packages/ui/src/navigation/menu/menu.tsx b/packages/ui/src/navigation/menu/menu.tsx index 4731797551b..dea3c10e55d 100644 --- a/packages/ui/src/navigation/menu/menu.tsx +++ b/packages/ui/src/navigation/menu/menu.tsx @@ -155,6 +155,26 @@ export interface MenuSubTriggerProps extends ComponentProps { + placement?: + | "top" + | "top-start" + | "top-end" + | "bottom" + | "bottom-start" + | "bottom-end" + | "left" + | "left-start" + | "left-end" + | "right" + | "right-start" + | "right-end" +} + +function MenuSub(props: MenuSubProps) { + return +} + function MenuSubTrigger(props: ParentProps) { const ctx = useMenuContext() const [s, r] = splitProps(props, ["class", "classList", "children", "shortcut", "badge"]) @@ -282,7 +302,7 @@ export const Menu = Object.assign(MenuRoot, { Group: DropdownMenu.Group, GroupLabel: MenuGroupLabel, Separator: MenuSeparator, - Sub: DropdownMenu.Sub, + Sub: MenuSub, SubTrigger: MenuSubTrigger, SubContent: MenuSubContent, Context: MenuContext,