From de417edf241e9bd3307228a00b8a2ac90746e728 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:49:06 +0200 Subject: [PATCH] feat(desktop): manage provider accounts and restore Console connect intro (#54101) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> --- packages/app/e2e/regression/composer.spec.ts | 51 ++ packages/app/e2e/regression/settings.spec.ts | 458 ++++++++++++++++++ packages/app/e2e/utils/mock-api.ts | 13 + packages/app/e2e/utils/mock-server.ts | 41 +- .../app/src/providers/connect/controller.ts | 11 +- packages/app/src/providers/connect/dialog.tsx | 351 ++++++++++---- packages/app/src/providers/connect/models.css | 4 +- .../models/select-dialog.stories.tsx | 1 + .../src/providers/models/select-dialog.tsx | 40 +- packages/app/src/runtime/i18n/en.ts | 19 +- .../src/settings/providers/account-actions.ts | 103 ++++ .../src/settings/providers/account-name.tsx | 69 +++ .../app/src/settings/providers/providers.tsx | 273 +++++------ packages/app/src/settings/settings.css | 48 +- packages/ui/src/context/dialog.tsx | 3 + packages/ui/src/data-display/badge/badge.css | 5 + .../src/data-display/badge/badge.stories.tsx | 11 +- packages/ui/src/data-display/badge/badge.tsx | 2 +- 18 files changed, 1237 insertions(+), 266 deletions(-) create mode 100644 packages/app/src/settings/providers/account-actions.ts create mode 100644 packages/app/src/settings/providers/account-name.tsx diff --git a/packages/app/e2e/regression/composer.spec.ts b/packages/app/e2e/regression/composer.spec.ts index 544ebfd3b4b..87fe50661df 100644 --- a/packages/app/e2e/regression/composer.spec.ts +++ b/packages/app/e2e/regression/composer.spec.ts @@ -404,6 +404,57 @@ for (const row of [ }) } +test("marks a Console provider with its workspace only when it is also connected directly", async ({ page }) => { + const model = (id: string, name: string) => ({ [id]: { id, name, limit: { context: 200_000 } } }) + + const managed = (id: string, canonical: string, name: string, models: ReturnType) => ({ + id, + canonical, + integrationID: "opencode", + name: `Anomaly / ${name}`, + models, + }) + + await openSession(page, { + name: "ComposerConsoleProviders", + provider: { + all: [ + managed("opencode", "opencode", "OpenCode", model("console-model", "Console Model")), + managed("console-google", "google", "Google", model("managed-gemini", "Managed Gemini")), + managed("console-openai", "openai", "OpenAI", model("managed-gpt", "Managed GPT")), + { id: "google", canonical: "google", name: "Google", models: model("direct-gemini", "Direct Gemini") }, + ], + connected: ["opencode", "console-google", "console-openai", "google"], + default: { providerID: "opencode", modelID: "console-model" }, + }, + seed: { + storage: { + "opencode.global.dat:model": { + user: [ + ["opencode", "console-model"], + ["console-google", "managed-gemini"], + ["console-openai", "managed-gpt"], + ["google", "direct-gemini"], + ].map(([providerID, modelID]) => ({ providerID, modelID, visibility: "show" })), + recent: [], + variant: {}, + }, + }, + }, + }) + + await page.locator('[data-component="composer"] [data-action="composer-model"]').click() + const menu = page.getByRole("menu") + await expect(menu.getByRole("menuitemradio", { name: "Direct Gemini", exact: true })).toBeVisible() + const headings = menu.locator('[data-slot="menu-v2-group-label"]') + await expect(headings).toHaveCount(4) + await expect(headings.filter({ hasText: /^GoogleAnomaly$/ })).toHaveCount(1) + await expect(headings.filter({ hasText: /^Google$/ })).toHaveCount(1) + await expect(headings.filter({ hasText: /^OpenAI$/ })).toHaveCount(1) + await expect(headings.filter({ hasText: /^OpenCode$/ })).toHaveCount(1) + await expect(menu.getByText(/Anomaly \//)).toHaveCount(0) +}) + test("shows thinking on hover or a non-default selection while preserving keyboard access", async ({ page }) => { const { editor: input } = await openSession(page, { name: "ComposerThinking", diff --git a/packages/app/e2e/regression/settings.spec.ts b/packages/app/e2e/regression/settings.spec.ts index 01ac1fc08f2..bd1c1f4efe6 100644 --- a/packages/app/e2e/regression/settings.spec.ts +++ b/packages/app/e2e/regression/settings.spec.ts @@ -750,6 +750,464 @@ for (const row of [ }) } +const consoleIntegrations = [ + { + id: "opencode", + name: "OpenCode Console", + methods: [ + { id: "device", type: "oauth", label: "OpenCode Console account" }, + { type: "key", label: "API key (service account)" }, + ], + connections: [], + }, + { id: "opencode-go", name: "OpenCode Go", methods: [{ type: "key" }], connections: [] }, +] + +for (const row of [ + { provider: "OpenCode Console", connect: "Connect OpenCode Console", signIn: "Connecting to OpenCode Console" }, + { provider: "OpenCode Go", connect: "Connect OpenCode Go", signIn: "Connecting to OpenCode Go" }, +]) { + test(`providers: ${row.provider} signs in from the Console intro`, async ({ page, context }) => { + const url = "https://auth.example.test/device?user_code=ABCD-EFGH" + await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" })) + const { settings } = await open(page, { integrations: consoleIntegrations, onIntegrationOAuth: () => ({ url }) }) + + // Registered after the mock server so it sees the sign-in request first. + const start = await holdRoute(page, (url) => url.pathname === "/api/integration/opencode/connect/oauth", { + method: "POST", + }) + + await settings.getByRole("tab", { name: "Providers", exact: true }).click() + await settings + .locator(".settings-provider-row", { hasText: row.provider }) + .getByRole("button", { name: "Connect", exact: true }) + .click() + const intro = page.getByRole("dialog", { name: row.connect, exact: true }) + const container = page.locator('[data-slot="dialog-container"]') + await expect( + intro.getByText("Sign in with your OpenCode Console account to use the available models."), + ).toBeVisible() + await expect(intro.getByRole("button", { name: "Use API key", exact: true })).toBeVisible() + expect((await container.boundingBox())?.height).toBe(512) + + const popup = page.waitForEvent("popup") + await intro.getByRole("button", { name: "Continue in the browser", exact: true }).click() + await start.arrived + await expect(intro.getByRole("button", { name: "Opening browser…", exact: true })).toBeDisabled() + expect((await container.boundingBox())?.height).toBe(512) + start.release() + await expect(await popup).toHaveURL(url) + + const signIn = page.getByRole("dialog", { name: row.signIn, exact: true }) + await expect(signIn.getByRole("group", { name: "Device code: ABCD-EFGH", exact: true })).toBeVisible() + await expect(signIn.getByRole("button", { name: "Copy sign-in link", exact: true })).toBeVisible() + await expect(signIn.getByRole("button", { name: "Use API key", exact: true })).toHaveCount(0) + await expect.poll(async () => (await container.boundingBox())?.height).toBeLessThan(512) + }) +} + +test("providers: the connect dialog lists connected providers and manages their accounts", async ({ page }) => { + const accounts = [ + { type: "credential", id: "cred_work", label: "Work", method: "key" }, + { type: "credential", id: "cred_personal", label: "Personal", method: "key" }, + ] + + const state = { active: "cred_work" } + const activated: string[] = [] + + const { settings } = await open(page, { + integrations: () => [ + { + id: "openai", + name: "OpenAI", + methods: [{ type: "key", label: "API key" }], + connections: accounts.toSorted((a, b) => Number(b.id === state.active) - Number(a.id === state.active)), + }, + { id: "anthropic", name: "Anthropic", methods: [{ type: "key", label: "API key" }], connections: [] }, + ], + onCredentialActivate: (credentialID) => { + activated.push(credentialID) + state.active = credentialID + }, + onCredentialUpdate: (credentialID, body) => { + const account = accounts.find((item) => item.id === credentialID) + + if (account && body.label) account.label = body.label + }, + }) + + await settings.getByRole("tab", { name: "Providers", exact: true }).click() + await settings.getByRole("button", { name: "Show more providers", exact: true }).click() + const dialog = page.getByRole("dialog") + const openai = dialog.locator('[data-provider-id="openai"]') + const count = openai.locator('[data-component="tag"]') + await expect(count).toHaveText("2") + await expect(count).toHaveAttribute("aria-label", "2 accounts") + await expect(dialog.locator('[data-provider-id="anthropic"]').locator('[data-component="tag"]')).toHaveCount(0) + await openai.click() + + await expect(page.getByRole("dialog", { name: "OpenAI", exact: true })).toBeVisible() + const list = dialog.getByRole("radiogroup", { name: "Connected accounts", exact: true }) + await expect(list.getByRole("radio", { name: "Work", exact: true })).toHaveAttribute("aria-checked", "true") + // Rows keep their order when the active account changes. + await expect(list.getByRole("radio")).toHaveText(["Personal", "Work"]) + await list.getByRole("radio", { name: "Personal", exact: true }).click() + await expect(list.getByRole("radio", { name: "Personal", exact: true })).toHaveAttribute("aria-checked", "true") + await expect(list.getByRole("radio")).toHaveText(["Personal", "Work"]) + expect(activated).toEqual(["cred_personal"]) + + await list.getByRole("button", { name: "Personal options", exact: true }).click() + await page.getByRole("menuitem", { name: "Rename", exact: true }).click() + const name = list.getByRole("textbox", { name: "Account name", exact: true }) + await expect(name).toBeFocused() + await expect(name).toHaveValue("Personal") + await name.fill("Home") + await name.press("Enter") + await expect(list.getByRole("radio")).toHaveText(["Home", "Work"]) + await expect(list.getByRole("radio", { name: "Home", exact: true })).toHaveAttribute("aria-checked", "true") + + await list.getByRole("button", { name: "Home options", exact: true }).click() + await page.getByRole("menuitem", { name: "Rename", exact: true }).click() + await name.fill("Ignored") + await name.press("Escape") + await expect(list.getByRole("radio")).toHaveText(["Home", "Work"]) + await expect(page.getByRole("dialog", { name: "OpenAI", exact: true })).toBeVisible() + + await list.getByRole("button", { name: "Work options", exact: true }).click() + await page.getByRole("menuitem", { name: "Remove", exact: true }).click() + await expect(dialog.getByText("Remove Work?", { exact: true })).toBeVisible() + + const removed = page.waitForRequest( + (request) => request.method() === "DELETE" && new URL(request.url()).pathname === "/api/credential/cred_work", + ) + + await dialog.getByRole("button", { name: "Remove", exact: true }).click() + await removed + + await dialog.getByRole("button", { name: "Add another account", exact: true }).click() + await expect(dialog.getByPlaceholder("API key")).toBeVisible() + await dialog.getByRole("button", { name: "Navigate back", exact: true }).click() + await expect(list).toBeVisible() +}) + +test("providers: adding a Go account uses a Go key, and a lone sign-in starts once", async ({ page, context }) => { + await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" })) + const attempts: string[] = [] + + const { settings } = await open(page, { + integrations: [ + ...consoleIntegrations.filter((integration) => integration.id !== "opencode-go"), + { + id: "opencode-go", + name: "OpenCode Go", + methods: [{ type: "key" }], + connections: [{ type: "credential", id: "cred_go", label: "Go key", method: "key" }], + }, + { + id: "github-copilot", + name: "GitHub Copilot", + methods: [{ id: "device", type: "oauth", label: "GitHub" }], + connections: [{ type: "credential", id: "cred_gh", label: "Octocat", method: "oauth" }], + }, + ], + onIntegrationOAuth: (input) => { + attempts.push(input.integrationID) + + return { url: "https://auth.example.test/device?user_code=GH12-3456" } + }, + }) + + await settings.getByRole("tab", { name: "Providers", exact: true }).click() + await settings.getByRole("button", { name: "Show more providers", exact: true }).click() + const dialog = page.getByRole("dialog") + await dialog.locator('[data-provider-id="opencode-go"]').click() + await dialog.getByRole("button", { name: "Add another account", exact: true }).click() + await expect(dialog.getByPlaceholder("API key")).toBeVisible() + expect(attempts).toEqual([]) + + await dialog.getByRole("button", { name: "Navigate back", exact: true }).click() + await expect(dialog.getByRole("radio", { name: "Go key", exact: true })).toBeVisible() + await dialog.getByRole("button", { name: "Navigate back", exact: true }).click() + await dialog.locator('[data-provider-id="github-copilot"]').click() + const popup = page.waitForEvent("popup") + await dialog.getByRole("button", { name: "Add another account", exact: true }).click() + await popup + await expect.poll(() => attempts).toEqual(["github-copilot"]) + expect(attempts).toEqual(["github-copilot"]) +}) + +test("providers: the account options rename and remove the active account", async ({ page }) => { + const account = { type: "credential", id: "cred_work", label: "Work", method: "key" } + const other = { type: "credential", id: "cred_personal", label: "Personal", method: "key" } + const removed: string[] = [] + + const { settings } = await open(page, { + provider: { all: [{ id: "openai", name: "OpenAI", models: {} }], connected: ["openai"], default: {} }, + integrations: () => [ + { + id: "openai", + name: "OpenAI", + methods: [{ type: "key", label: "API key" }], + connections: [account, other].filter((item) => !removed.includes(item.id)), + }, + ], + onCredentialUpdate: (credentialID, body) => { + const target = [account, other].find((item) => item.id === credentialID) + + if (target && body.label) target.label = body.label + }, + }) + + page.on("request", (request) => { + const path = new URL(request.url()).pathname + + if (request.method() === "DELETE" && path.startsWith("/api/credential/")) removed.push(path.split("/").at(-1)!) + }) + + await settings.getByRole("tab", { name: "Providers", exact: true }).click() + const trigger = settings.getByRole("button", { name: "Manage OpenAI accounts", exact: true }) + await expect(trigger).toHaveText("Work") + + await trigger.click() + await expect(page.getByRole("menuitemradio", { name: "Work", exact: true })).toHaveAttribute("aria-checked", "true") + await expect(page.getByRole("menuitem", { name: "Add account", exact: true })).toBeVisible() + await page.keyboard.press("Escape") + + const options = settings.getByRole("button", { name: "Work options", exact: true }) + await expect(options).toHaveCSS("height", "28px") + await options.click() + await page.getByRole("menuitem", { name: "Rename", exact: true }).click() + const name = settings.getByRole("textbox", { name: "Account name", exact: true }) + await expect(name).toBeFocused() + await expect(name).toHaveValue("Work") + await name.fill("Team") + await name.press("Enter") + await expect(trigger).toHaveText("Team") + expect(other.label).toBe("Personal") + + await settings.getByRole("button", { name: "Team options", exact: true }).click() + await page.getByRole("menuitem", { name: "Rename", exact: true }).click() + await name.fill("Ignored") + await name.press("Escape") + await expect(trigger).toHaveText("Team") + + await settings.getByRole("button", { name: "Team options", exact: true }).click() + await page.getByRole("menuitem", { name: "Rename", exact: true }).click() + await name.fill("Shared") + await settings.getByRole("tab", { name: "Providers", exact: true }).click() + await expect(trigger).toHaveText("Shared") + + await settings.getByRole("button", { name: "Shared options", exact: true }).click() + await page.getByRole("menuitem", { name: "Remove", exact: true }).click() + await expect.poll(() => removed).toEqual(["cred_work"]) +}) + +test("providers: switching to a Zen API key does not wait for a Console workspace", async ({ page }) => { + const accounts = [ + { type: "credential", id: "cred_console", label: "Anomaly", method: "oauth" }, + { type: "credential", id: "cred_zen", label: "Zen key", method: "key" }, + ] + + const state = { active: "cred_console" } + + const { settings } = await open(page, { + provider: () => + state.active === "cred_console" + ? { + all: [ + { id: "opencode", integrationID: "opencode", name: "Anomaly / OpenCode", models: {} }, + { id: "console-google", integrationID: "opencode", name: "Anomaly / Google", models: {} }, + ], + connected: ["opencode", "console-google"], + default: {}, + } + : { + all: [ + { + id: "opencode", + name: "OpenCode Zen", + models: { paid: { id: "paid", name: "Paid", cost: { input: 3 } } }, + }, + ], + connected: ["opencode"], + default: {}, + }, + integrations: () => [ + { + id: "opencode", + name: "OpenCode Console", + methods: [{ id: "device", type: "oauth", label: "OpenCode Console account" }], + connections: accounts.toSorted((a, b) => Number(b.id === state.active) - Number(a.id === state.active)), + }, + ], + onCredentialActivate: (credentialID) => { + state.active = credentialID + }, + }) + + await settings.getByRole("tab", { name: "Providers", exact: true }).click() + const trigger = settings.getByRole("button", { name: "Manage OpenCode Console accounts", exact: true }) + await expect(trigger).toHaveText("Anomaly") + await trigger.click() + + const catalogReads: string[] = [] + + page.on("request", (request) => { + if (request.method() === "GET" && new URL(request.url()).pathname === "/api/provider") + catalogReads.push(request.url()) + }) + await page.getByRole("menuitemradio", { name: "Zen key", exact: true }).click() + await expect(page.getByText("OpenCode Console account switched")).toBeVisible() + // One refresh after the switch; a Console workspace poll would keep reading the catalog. + expect(catalogReads).toHaveLength(1) +}) + +test("providers: connecting OpenCode Go keeps the Console row in place", async ({ page, context }) => { + await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" })) + + // After the sign-in the server reloads the workspace, briefly serving only the free catalog. + const state = { authorized: false, pendingReads: 0 } + + const workspace = { + all: [ + { id: "opencode", integrationID: "opencode", name: "Anomaly / OpenCode", models: {} }, + { id: "console-google", integrationID: "opencode", name: "Anomaly / Google", models: {} }, + ], + connected: ["opencode", "console-google"], + default: {}, + } + + const catalog = () => { + if (state.pendingReads > 0) { + state.pendingReads-- + + return { all: [{ id: "opencode", name: "OpenCode", models: {} }], connected: ["opencode"], default: {} } + } + + if (!state.authorized) return workspace + + return { + all: [...workspace.all, { id: "opencode-go", integrationID: "opencode-go", name: "OpenCode Go", models: {} }], + connected: [...workspace.connected, "opencode-go"], + default: {}, + } + } + + const { settings } = await open(page, { + provider: catalog, + integrations: () => [ + { + id: "opencode", + name: "OpenCode Console", + methods: [{ id: "device", type: "oauth", label: "OpenCode Console account" }], + connections: [{ type: "credential", id: "cred_console", label: "Anomaly", method: "oauth" }], + }, + { + id: "opencode-go", + name: "OpenCode Go", + methods: [{ type: "key" }], + connections: state.authorized ? [{ type: "credential", id: "cred_go", label: "Anomaly", method: "oauth" }] : [], + }, + ], + onIntegrationOAuth: () => ({ url: "https://auth.example.test/device?user_code=GOGO-1234" }), + onIntegrationOAuthStatus: () => { + if (!state.authorized) { + state.authorized = true + state.pendingReads = 3 + } + + return "complete" + }, + }) + + await settings.getByRole("tab", { name: "Providers", exact: true }).click() + const connected = settings.locator('[data-component="connected-providers-section"]') + await expect(connected.getByText("OpenCode Console", { exact: true })).toBeVisible() + await connected.evaluate((element) => { + new MutationObserver(() => { + if (!element.textContent?.includes("OpenCode Console")) element.dataset.flashed = "" + }).observe(element, { childList: true, subtree: true, characterData: true }) + }) + + await settings + .locator(".settings-provider-row", { hasText: "OpenCode Go" }) + .getByRole("button", { name: "Connect", exact: true }) + .click() + const popup = page.waitForEvent("popup") + await page.getByRole("button", { name: "Continue in the browser", exact: true }).click() + await popup + await expect(page.getByText("OpenCode Go connected", { exact: true })).toBeVisible() + await expect(page.getByRole("dialog")).toHaveCount(0) + + await expect(connected.getByText("OpenCode Go", { exact: true })).toBeVisible() + await expect(connected.getByText("OpenCode Console", { exact: true })).toBeVisible() + await expect(connected).not.toHaveAttribute("data-flashed") +}) + +test("providers: switching Console accounts keeps the list until the new workspace loads", async ({ page }) => { + const accounts = [ + { type: "credential", id: "cred_clara", label: "Clara", method: "oauth" }, + { type: "credential", id: "cred_anomaly", label: "Anomaly", method: "oauth" }, + ] + + // The server loads the new workspace's providers a few catalog reads after the switch. + const state = { active: "cred_clara", pendingReads: 0 } + const workspace = () => accounts.find((account) => account.id === state.active)!.label + + const catalog = () => { + if (state.pendingReads > 0) { + state.pendingReads-- + + return { + all: [{ id: "opencode", name: "OpenCode", models: { free: { id: "free", name: "Free", cost: { input: 0 } } } }], + connected: ["opencode"], + default: {}, + } + } + + return { + all: [ + { id: "opencode", integrationID: "opencode", name: `${workspace()} / OpenCode`, models: {} }, + { id: "console-google", integrationID: "opencode", name: `${workspace()} / Google`, models: {} }, + ], + connected: ["opencode", "console-google"], + default: {}, + } + } + + const { settings } = await open(page, { + provider: catalog, + integrations: () => [ + { + id: "opencode", + name: "OpenCode Console", + methods: [{ id: "device", type: "oauth", label: "OpenCode Console account" }], + connections: accounts.toSorted((a, b) => Number(b.id === state.active) - Number(a.id === state.active)), + }, + ], + onCredentialActivate: (credentialID) => { + state.active = credentialID + state.pendingReads = 2 + }, + }) + + await settings.getByRole("tab", { name: "Providers", exact: true }).click() + const connected = settings.locator('[data-component="connected-providers-section"]') + const trigger = connected.getByRole("button", { name: "Manage OpenCode Console accounts", exact: true }) + await expect(trigger).toHaveText("Clara") + await connected.evaluate((element) => { + new MutationObserver(() => { + if (element.textContent?.includes("No connected providers")) element.dataset.flashed = "" + }).observe(element, { childList: true, subtree: true, characterData: true }) + }) + + await trigger.click() + await page.getByRole("menuitemradio", { name: "Anomaly", exact: true }).click() + await expect(page.getByText("OpenCode Console account switched")).toBeVisible() + await expect(trigger).toHaveText("Anomaly") + await expect(connected).not.toHaveAttribute("data-flashed") +}) + test("the add server dialog keeps focus above fullscreen settings", async ({ page }) => { await mockRemoteServer(page, { directory: "/remote/settings-demo" }) const { settings } = await open(page) diff --git a/packages/app/e2e/utils/mock-api.ts b/packages/app/e2e/utils/mock-api.ts index 31f67677554..781d78f63d2 100644 --- a/packages/app/e2e/utils/mock-api.ts +++ b/packages/app/e2e/utils/mock-api.ts @@ -125,6 +125,19 @@ const Group = HttpApiGroup.make("mock") error: MockNotFound.pipe(HttpApiSchema.status(404)), }), ) + .add( + HttpApiEndpoint.post("credentialActivate", "/api/credential/:credentialID/activate", { + params: { credentialID: Schema.String }, + success: NoContent, + }), + ) + .add( + HttpApiEndpoint.patch("credentialUpdate", "/api/credential/:credentialID", { + params: { credentialID: Schema.String }, + payload: JsonPayload, + success: NoContent, + }), + ) .add( HttpApiEndpoint.delete("credentialRemove", "/api/credential/:credentialID", { params: { credentialID: Schema.String }, diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 1d776408c7d..e15cda3e0fe 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -44,7 +44,11 @@ export type MockFileContent = export interface MockServerConfig { server?: string provider: Resolvable - integrations?: unknown[] + integrations?: Resolvable + // Runs on POST /api/credential/:id/activate; the fixture swaps its integrations and catalog to the new account. + onCredentialActivate?: (credentialID: string) => void + // Runs on PATCH /api/credential/:id with the request body; the fixture applies the new label. + onCredentialUpdate?: (credentialID: string, body: { label?: string }) => void onConnectKey?: (input: { integrationID: string; body: unknown }) => void // Terminal shells the settings offer (`/api/config/shell`). shells?: unknown[] @@ -103,6 +107,8 @@ export interface MockServerConfig { // Starts an OAuth attempt (POST .../connect/oauth) and returns its authorization URL; the attempt then stays pending. // Without it, OAuth connects answer 501 MockUnsupported. onIntegrationOAuth?: (input: { integrationID: string; directory: string; body: unknown }) => { url: string } + // Answers status polls for an OAuth attempt; without it every attempt stays pending. + onIntegrationOAuthStatus?: (attemptID: string) => "pending" | "complete" plugins?: Resolvable skills?: Resolvable // Replaces the `/api/worktree` inventory, which defaults to the directory plus project sandboxes. @@ -849,19 +855,20 @@ function mockHandlers( model: () => Effect.succeed({ location: location(config), data: currentModels(resolve(config.provider)) }), modelDefault: () => Effect.succeed({ location: location(config), data: currentDefaultModel(resolve(config.provider)) }), - integrationList: () => Effect.succeed({ location: location(config), data: config.integrations ?? [] }), + integrationList: () => + Effect.sync(() => ({ location: location(config), data: resolve(config.integrations ?? []) })), integrationGet: (ctx) => - Effect.succeed({ + Effect.sync(() => ({ location: location(config), - data: config.integrations - ?.filter(Predicate.isObject) + data: resolve(config.integrations ?? []) + .filter(Predicate.isObject) .find((integration) => integration.id === ctx.params.integrationID) ?? { id: ctx.params.integrationID, name: ctx.params.integrationID, methods: [{ type: "key", label: "API key" }], connections: [], }, - }), + })), integrationConnect: (ctx) => Effect.sync(() => config.onConnectKey?.({ integrationID: ctx.params.integrationID, body: ctx.payload })).pipe( Effect.andThen(noContent), @@ -898,9 +905,18 @@ function mockHandlers( return Effect.succeed({ location: location(config, requestDirectory(config, ctx.request)), - data: { status: "pending", time: { created, expires: created + 600_000 } }, + data: { + status: config.onIntegrationOAuthStatus?.(ctx.params.attemptID) ?? "pending", + time: { created, expires: created + 600_000 }, + }, }) }), + credentialActivate: (ctx) => + Effect.sync(() => config.onCredentialActivate?.(ctx.params.credentialID)).pipe(Effect.andThen(noContent)), + credentialUpdate: (ctx) => + Effect.sync(() => config.onCredentialUpdate?.(ctx.params.credentialID, ctx.payload)).pipe( + Effect.andThen(noContent), + ), credentialRemove: () => noContent, command: (ctx) => Effect.sync(() => ({ @@ -1404,6 +1420,8 @@ function currentProviders(catalog: MockProviderCatalog) { .map((provider) => ({ id: provider.id, name: provider.name, + canonical: provider.canonical, + integrationID: provider.integrationID, package: provider.id, activation: connected.has(provider.id) ? "enabled" : "auto", })) @@ -1535,7 +1553,14 @@ const decodePromptPayload = Schema.decodeUnknownOption( }), ) -const decodeProvider = Schema.decodeUnknownOption(Schema.Struct({ id: Schema.String, name: Schema.String })) +const decodeProvider = Schema.decodeUnknownOption( + Schema.Struct({ + id: Schema.String, + name: Schema.String, + canonical: lenient(Schema.String), + integrationID: lenient(Schema.String), + }), +) const decodeModelProvider = Schema.decodeUnknownOption( Schema.Struct({ id: Schema.String, models: Schema.Record(Schema.String, Schema.Unknown) }), diff --git a/packages/app/src/providers/connect/controller.ts b/packages/app/src/providers/connect/controller.ts index d9542c0c284..6ae0ba131d1 100644 --- a/packages/app/src/providers/connect/controller.ts +++ b/packages/app/src/providers/connect/controller.ts @@ -62,8 +62,8 @@ export function createProviderConnectionController(options: { keyProvider?: () => string directory: () => string | undefined onComplete: () => void - /** Picks the method to start without asking when the integration exposes several. */ - autoSelect?: (methods: ProviderConnectMethod[]) => number | undefined + /** Skips auto-selecting a lone method; the caller starts it. */ + manual?: () => boolean /** Runs after the catalogs refresh; returning false keeps the dialog on a retryable error. */ prepare?: (active: () => boolean) => Promise pollInterval?: number @@ -148,12 +148,9 @@ export function createProviderConnectionController(options: { ) const autoIndex = createMemo(() => { - if (integration.loading) return undefined - const values = methods() + if (integration.loading || options.manual?.()) return undefined - if (values.length === 1) return 0 - - return options.autoSelect?.(values) + return methods().length === 1 ? 0 : undefined }) type Action = diff --git a/packages/app/src/providers/connect/dialog.tsx b/packages/app/src/providers/connect/dialog.tsx index 6c4b4fda600..15fe5379bf3 100644 --- a/packages/app/src/providers/connect/dialog.tsx +++ b/packages/app/src/providers/connect/dialog.tsx @@ -4,6 +4,7 @@ import { useDialog } from "@opencode/ui/context/dialog" import { Icon } from "@opencode/ui/icon" import { List } from "@opencode/ui/list" import { RadioGroup, RadioItem } from "@opencode/ui/radio" +import { Loader } from "@opencode/ui/loader" import { Spinner } from "@opencode/ui/spinner" import { TextField } from "@opencode/ui/text-field" import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog" @@ -23,7 +24,6 @@ import { } from "solid-js" import { createStore } from "solid-js/store" import { useParams } from "@solidjs/router" -import { ExternalLink } from "@/runtime/platform/external-link" import { useLanguage } from "@/runtime/i18n/language" import { usePlatform } from "@/runtime/platform/platform" import { useServerSDK } from "@/runtime/server/client" @@ -43,7 +43,6 @@ import { Persist, persisted } from "@/runtime/persistence/storage" import { Persistence } from "@/runtime/persistence/schema" import { Schema } from "effect" import { - CONSOLE_INTEGRATION, CONSOLE_PROVIDERS, consoleIntegration, createProviderConnectionController, @@ -53,6 +52,11 @@ import { import { ConsoleAuthorization } from "./console" import { DialogChatGPTPlanWelcome } from "./chatgpt-welcome" import { authServerName, RemoteAuthNotice } from "./remote" +import { providerAccounts, type ProviderAccount } from "@/settings/providers/accounts" +import { AccountNameInput } from "@/settings/providers/account-name" +import { useProviderAccountActions } from "@/settings/providers/account-actions" +import { IconButton } from "@opencode/ui/icon-button" +import { Menu } from "@opencode/ui/menu" import "./models.css" const CUSTOM_ID = "_custom" @@ -89,10 +93,12 @@ export const DialogConnectProvider: Component<{ modelProvider?: { id: string; name: string } authorization: boolean chatgptWelcome: boolean + picked: boolean }>({ completed: false, authorization: false, chatgptWelcome: false, + picked: false, }) const language = useLanguage() @@ -112,6 +118,7 @@ export const DialogConnectProvider: Component<{ const select = (provider?: string) => { back.current = reset + setState("picked", true) controller.select(provider) } @@ -128,6 +135,7 @@ export const DialogConnectProvider: Component<{ {(provider) => ( { props.onConnected?.(provider) - if (provider === "openai" && methodID === "chatgpt-token-sharing") - setState("chatgptWelcome", true) + if (provider === "openai" && methodID === "chatgpt-token-sharing") setState("chatgptWelcome", true) }} onFirstConnection={(provider) => setState("modelProvider", provider)} onAuthorization={(authorization) => setState("authorization", authorization)} @@ -177,7 +184,8 @@ export const DialogConnectProvider: Component<{ }} class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]" classList={{ - "[&_[data-slot=dialog-header]]:!pt-4 [&_[data-slot=dialog-header]]:!pb-3": consoleSelected() && !state.modelProvider, + "[&_[data-slot=dialog-header]]:!pt-4 [&_[data-slot=dialog-header]]:!pb-3": + consoleSelected() && !state.modelProvider, "[&_[data-slot=dialog-header]]:!pt-5": !!state.modelProvider, }} > @@ -232,29 +240,11 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string const featured = ["opencode-go", "opencode", "anthropic", "openai", "google", "openrouter", "vercel"] const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") }) - // Only a stored credential hides a provider: environment and config connections can still be - // replaced by a sign-in. OpenCode Zen stays until a Console account (not a key) is connected. - const consoleAccount = createMemo(() => - integrations - .list() - .find((integration) => integration.id === CONSOLE_INTEGRATION) - ?.connections.some((connection) => connection.type === "credential" && connection.method === "oauth"), - ) - const all = createMemo(() => { language.locale() const query = store.filter.trim().toLowerCase() - const values = [ - custom(), - ...integrations - .list() - .filter((integration) => - integration.id === CONSOLE_INTEGRATION - ? !consoleAccount() - : !integration.connections.some((connection) => connection.type === "credential"), - ), - ] + const values = [custom(), ...integrations.list()] if (!query) return values @@ -280,6 +270,10 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string ) const rows = createMemo(() => [...popular(), ...other()]) + + const accountCount = (provider: { id: string }) => + providerAccounts(integrations.list().find((integration) => integration.id === provider.id)).length + let picker: HTMLDivElement | undefined let search: HTMLInputElement | undefined @@ -356,7 +350,18 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string > {provider.name} - + 0}> + + {accountCount(provider)} + + + {language.t( provider.id === "opencode" @@ -409,6 +414,7 @@ function ProviderConnection(props: { onConnected?: (methodID?: string) => void onFirstConnection: (provider: { id: string; name: string }) => void onAuthorization: (authorization: boolean) => void + accounts?: boolean }) { const dialog = useDialog() const params = useParams() @@ -424,6 +430,7 @@ function ProviderConnection(props: { const location = () => (initialDirectory ? { directory: initialDirectory } : undefined) const providers = useProviders(directory) const integrations = useIntegrations(directory) + const accountActions = useProviderAccountActions(directory) const integrationID = consoleIntegration(props.provider) const isConsole = CONSOLE_PROVIDERS.has(props.provider) const remote = isConsole && authServerName(sdk.server) !== undefined @@ -437,7 +444,15 @@ function ProviderConnection(props: { catalogPending: boolean selectedModel: string collapsed: Record + adding: boolean + direct: boolean + confirming?: string + switching?: string + activating?: string + renaming?: string }>({ + adding: false, + direct: false, copied: false, copyFailed: false, models: false, @@ -448,18 +463,23 @@ function ProviderConnection(props: { collapsed: {}, }) + // Sorted by name so rows stay put when the server moves the active account first. + const serverAccounts = () => + providerAccounts(integrations.list().find((integration) => integration.id === props.provider)) + + const accounts = () => serverAccounts().toSorted((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id)) + + const activeAccount = () => state.activating ?? serverAccounts()[0]?.id + + const showAccounts = () => !!props.accounts && !state.adding && accounts().length > 0 + const controller = createProviderConnectionController({ provider: () => integrationID, // A Go service-account key still belongs to the `opencode-go` integration (zen/go/v1), // exactly as before; only the sign-in is shared with the Console. keyProvider: () => props.provider, directory, - autoSelect: (methods) => { - if (!isConsole) return undefined - const index = methods.findIndex((method) => method.type === "oauth") - - return index === -1 ? undefined : index - }, + manual: () => isConsole || (!!props.accounts && accounts().length > 0), prepare: isConsole ? prepareConsoleCatalog : undefined, pollInterval: isConsole ? 500 : undefined, onComplete: () => { @@ -467,9 +487,7 @@ function ProviderConnection(props: { props.onConnected?.(method?.type === "oauth" ? method.id : undefined) // The picker only lists the newest model per family by default, which hides most of // what a new connection just unlocked. Show everything the connected integration offers. - global.models.show( - connectionModels().map((model) => ({ providerID: model.providerID, modelID: model.id })), - ) + global.models.show(connectionModels().map((model) => ({ providerID: model.providerID, modelID: model.id }))) if (state.catalogPending) { setState("noModels", true) @@ -593,7 +611,9 @@ function ProviderConnection(props: { }) createEffect(() => { const current = controller.auth.state() - props.onAuthorization(controller.authorization() !== undefined && (current === "waiting" || current === "refreshing")) + props.onAuthorization( + controller.authorization() !== undefined && (current === "waiting" || current === "refreshing"), + ) }) const provider = createMemo(() => ({ @@ -646,13 +666,17 @@ function ProviderConnection(props: { const keyIndex = () => controller.methods().findIndex((method) => method.type === "key") const oauthIndex = () => controller.methods().findIndex((method) => method.type === "oauth") - // The Console device flow owns the dialog from the first frame until the catalogs are loaded. const consoleSignIn = () => isConsole && !state.noModels && - controller.currentMethod()?.type !== "key" && + controller.currentMethod()?.type === "oauth" && controller.auth.state() !== "error" && - (controller.busy() || controller.authorization()?.mode === "auto") + controller.authorization()?.mode === "auto" + + const consoleStarting = () => controller.currentMethod()?.type === "oauth" && controller.auth.state() === "pending" + + const consoleIntro = () => + isConsole && !controller.loading() && (controller.methodIndex() === undefined || consoleStarting()) function AuthFormView() { const defaults = providerFormDefaults(controller.currentMethod()?.form) @@ -889,15 +913,19 @@ function ProviderConnection(props: { } function goBack() { - // The API key path for the Console is an escape hatch below the sign-in flow, so - // "back" returns to the sign-in rather than leaving the provider. - if (isConsole && controller.currentMethod()?.type === "key" && oauthIndex() !== -1) { - void controller.auth.select(oauthIndex()) + if ( + props.accounts && + state.adding && + accounts().length > 0 && + (state.direct || controller.methodIndex() === undefined) + ) { + controller.auth.reset() + setState({ adding: false, direct: false }) return } - if (!isConsole && controller.methods().length > 1 && controller.methodIndex() !== undefined) { + if ((isConsole || controller.methods().length > 1) && controller.methodIndex() !== undefined) { controller.auth.reset() return @@ -952,7 +980,7 @@ function ProviderConnection(props: { function ErrorRow() { return ( -
+
@@ -961,8 +989,8 @@ function ProviderConnection(props: { : language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}
-
) @@ -1001,21 +1029,15 @@ function ProviderConnection(props: { } return ( -
- -
- {language.t("provider.connect.console.apiKey.description")}{" "} - - {language.t("provider.connect.console.apiKey.link")} - -
-
+
+

+ {isConsole + ? language.t("provider.connect.console.serviceKeyDescription") + : language.t("provider.connect.apiKey.description", { provider: provider().name })} +

)} - @@ -1143,22 +1170,170 @@ function ProviderConnection(props: { ) } - // Deliberately quiet: most people should never need a key, so this stays small and at the bottom. - function ConsoleApiKeySwitch() { + const addAccount = () => { + setState({ adding: true, direct: false, confirming: undefined }) + + // Go signs in through the Console, which would add a Console account, so Go adds a key instead. + const index = + props.provider === "opencode-go" ? keyIndex() : !isConsole && controller.methods().length === 1 ? 0 : -1 + + if (index === -1) return + setState("direct", true) + void controller.auth.select(index) + } + + const activateAccount = async (account: ProviderAccount) => { + if (activeAccount() === account.id || state.switching) return + setState({ switching: account.id, activating: account.id, confirming: undefined }) + await accountActions.activate({ integrationID: props.provider, provider: provider().name, account }) + setState({ switching: undefined, activating: undefined }) + } + + const removeAccount = async (account: ProviderAccount) => { + setState({ switching: account.id, confirming: undefined }) + await accountActions.remove({ + integrationID: props.provider, + provider: provider().name, + account, + final: accounts().length === 1, + }) + setState("switching", undefined) + } + + function AccountsView() { return ( -
- {language.t("provider.connect.console.serviceAccount")} - + +
+ } + > +
+ + accountActions.rename(account, label)} + onClose={() => setState("renaming", undefined)} + /> +
+ } + > + + +
+ + } + disabled={state.switching !== undefined} + aria-label={language.t("dialog.provider.accounts.options", { account: account.label })} + /> + {/* The modal dialog aria-hides anything added outside it, this portal included, a frame + after it opens. Kobalte leaves a node marked as a top layer visible. */} + { + el.dataset.reactAriaTopLayer = "true" + }} + > + state.renaming && event.preventDefault()}> + setState({ renaming: account.id, confirming: undefined })}> + {language.t("common.rename")} + + setState({ confirming: account.id, renaming: undefined })}> + {language.t("dialog.provider.accounts.remove")} + + + + +
+
+ + )} + +
+ + ) } + function ConsoleIntro() { + return ( +
+

{language.t("provider.connect.console.intro")}

+ + +
+ {language.t("provider.connect.console.serviceAccount")} + +
+
+
+ ) + } + function ConsoleNoModels() { return (
@@ -1352,7 +1527,17 @@ function ProviderConnection(props: {
- {language.t("provider.connect.console.title")} + {provider().name} + + {language.t("provider.connect.opencode.errorTitle")} + + + {language.t( + props.provider === "opencode-go" + ? "provider.connect.console.titleGo" + : "provider.connect.console.title", + )} + {language.t("provider.connect.title.anthropicProMax")} + + {language.t("provider.connect.title", { provider: language.t("provider.connect.opencode.name") })} + {language.t("provider.connect.title", { provider: provider().name })} @@ -1390,6 +1578,12 @@ function ProviderConnection(props: { />
+ + + + + +
{language.t("provider.connect.status.inProgress")} @@ -1416,17 +1610,6 @@ function ProviderConnection(props: { - - -
diff --git a/packages/app/src/providers/connect/models.css b/packages/app/src/providers/connect/models.css index 36a01919cc0..6f791cddca8 100644 --- a/packages/app/src/providers/connect/models.css +++ b/packages/app/src/providers/connect/models.css @@ -56,9 +56,7 @@ } @media (hover: hover) { - [data-component="first-provider-models"] - .provider-model-groups--dialog - .provider-model-group-trigger:not(:disabled):hover { + [data-component="first-provider-models"] .provider-model-groups--dialog .provider-model-group-header:hover { background-color: var(--v2-background-bg-layer-03); } } diff --git a/packages/app/src/providers/models/select-dialog.stories.tsx b/packages/app/src/providers/models/select-dialog.stories.tsx index f487317f5f6..3701ddc242f 100644 --- a/packages/app/src/providers/models/select-dialog.stories.tsx +++ b/packages/app/src/providers/models/select-dialog.stories.tsx @@ -58,6 +58,7 @@ function SelectorStory(props: { plan: boolean }) { {models.find((model) => model.id === current())?.name} )} + all={() => models} models={(search) => models.filter((model) => model.name.toLowerCase().includes(search.toLowerCase()))} groups={(items) => [{ category: "openai", items }]} current={`openai:${current()}`} diff --git a/packages/app/src/providers/models/select-dialog.tsx b/packages/app/src/providers/models/select-dialog.tsx index b1e4f8a053f..929f4385f35 100644 --- a/packages/app/src/providers/models/select-dialog.tsx +++ b/packages/app/src/providers/models/select-dialog.tsx @@ -30,6 +30,7 @@ import { ProviderModelIcon, ProviderModelSections, } from "@/providers/models/provider-group" +import { consoleProviderName } from "@/providers/catalog/console" import "@/settings/settings.css" import "./select-dialog.css" @@ -299,6 +300,7 @@ export function ModelSelectorPopover(props: { fallback={ ModelItem[] models: (search: string) => ModelItem[] groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[] current: string | undefined @@ -512,6 +515,21 @@ export function ModelSelectorPopoverView(props: { const groups = createMemo(() => props.groups(models())) const keys = () => [...groups().flatMap((group) => group.items.map(modelKey)), manageKey] + const managed = createMemo(() => consoleModelGroup(props.all())) + const managedIDs = createMemo(() => new Set(managed()?.providers.map((provider) => provider.id))) + const canonical = (provider: ModelItem["provider"]) => provider.canonical ?? provider.id.replace(/^console-/, "") + + // The workspace badge only disambiguates a managed provider that is also connected directly. + const direct = createMemo( + () => + new Set( + props + .all() + .filter((item) => !managedIDs().has(item.provider.id)) + .map((item) => canonical(item.provider)), + ), + ) + const initialActive = () => { const selected = props.current const options = keys() @@ -677,8 +695,26 @@ export function ModelSelectorPopoverView(props: { {(group) => ( - - {group.items[0].provider.name} + + + {group.items[0].provider.name}} + > + {(workspace) => ( + <> + + {consoleProviderName(workspace(), group.items[0].provider.name)} + + + {workspace().workspace} + + + )} + diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index be98489287c..4868b309118 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -118,11 +118,24 @@ export const dict = { "dialog.model.unpaid.viewMoreProviders": "See 70+ more providers", "dialog.provider.viewAll": "Show more providers", + "dialog.provider.accounts.count.one": "{{count}} account", + "dialog.provider.accounts.count.other": "{{count}} accounts", + "dialog.provider.accounts.add": "Add another account", + "dialog.provider.accounts.connected": "Connected accounts", + "dialog.provider.accounts.removeConfirm": "Remove {{account}}?", + "dialog.provider.accounts.remove": "Remove", + "dialog.provider.accounts.options": "{{account}} options", "provider.connect.title": "Connect {{provider}}", "provider.connect.opencode.name": "OpenCode Console", "provider.connect.opencode.freeName": "OpenCode Free", + "provider.connect.opencode.errorTitle": "Connect to OpenCode", "provider.connect.console.title": "Connecting to OpenCode Console", + "provider.connect.console.titleGo": "Connecting to OpenCode Go", + "provider.connect.console.intro": "Sign in with your OpenCode Console account to use the available models.", + "provider.connect.console.continue": "Continue in the browser", + "provider.connect.console.retry": "Try again", + "provider.connect.console.serviceKeyDescription": "Connect using a service-account API key from OpenCode Console.", "provider.connect.console.instructions": "Continue in your browser. Confirm the code shown there matches the one below.", "provider.connect.console.deviceCode": "Device code", @@ -191,9 +204,6 @@ export const dict = { "Your browser opens so you can sign in to {{provider}}. Paste the authorization code it gives you below.", "provider.connect.oauth.openBrowser": "Open browser", "provider.connect.oauth.expired": "Authorization expired", - "provider.connect.console.apiKey.description": - "Paste an API key for a service account. You create service accounts in the OpenCode Console under Keys.", - "provider.connect.console.apiKey.link": "Open the Console", "provider.connect.toast.connected.title": "{{provider}} connected", "provider.connect.toast.connected.description": "{{provider}} models are now available to use.", @@ -916,8 +926,7 @@ export const dict = { "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.name": "Account name", "settings.providers.account.switched.title": "{{provider}} account switched", "settings.providers.account.switched.description": "Now using {{account}}.", "settings.providers.account.removed.title": "{{account}} removed", diff --git a/packages/app/src/settings/providers/account-actions.ts b/packages/app/src/settings/providers/account-actions.ts new file mode 100644 index 00000000000..ab5f83b3289 --- /dev/null +++ b/packages/app/src/settings/providers/account-actions.ts @@ -0,0 +1,103 @@ +import { showToast } from "@/shell/notifications/toast" +import { consoleProviderGroup } from "@/providers/catalog/console" +import { useIntegrations } from "@/providers/catalog/integrations" +import { useProviders } from "@/providers/catalog/providers" +import { CONSOLE_INTEGRATION } from "@/providers/connect/controller" +import { useLanguage } from "@/runtime/i18n/language" +import { useServerSDK } from "@/runtime/server/client" +import { useData } from "@/runtime/server/current" +import { formatServerError } from "@/runtime/server/errors" +import { activeProviderAccount, type ProviderAccount } from "./accounts" + +// Shared by the connect dialog and Settings so both switch, rename and remove accounts the same way. +export function useProviderAccountActions(directory: () => string | undefined) { + const language = useLanguage() + const sdk = useServerSDK() + const data = useData() + const providers = useProviders(directory) + const integrations = useIntegrations(directory) + + const location = () => { + const value = directory() + + return value ? { directory: value } : undefined + } + + const refresh = async () => { + const ref = location() + data.location.integration.invalidate(ref) + data.location.provider.invalidate(ref) + data.location.model.invalidate(ref) + await Promise.all([ + data.location.integration.sync(ref), + data.location.provider.sync(ref), + data.location.model.sync(ref), + ]) + } + + // The server loads a Console workspace's providers after the credential changes, so the first + // refresh can still show only the free catalog. Poll briefly before reporting the new account. + // A Zen API key shares the Console integration but has no workspace to wait for. + const settle = async (integrationID: string) => { + const account = activeProviderAccount(integrations.list().find((integration) => integration.id === integrationID)) + + if (integrationID !== CONSOLE_INTEGRATION || account?.method !== "oauth") return + const deadline = Date.now() + 10_000 + + while (!consoleProviderGroup(providers.connected()) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 250)) + data.location.provider.invalidate(location()) + data.location.model.invalidate(location()) + await Promise.all([data.location.provider.sync(location()), data.location.model.sync(location())]) + } + } + + const fail = (message: string) => showToast({ title: language.t("common.requestFailed"), description: message }) + + const activate = (input: { integrationID: string; provider: string; account: ProviderAccount }) => + sdk.api.credential + .activate({ credentialID: input.account.id }) + .then(refresh) + .then(() => settle(input.integrationID)) + .then(() => + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("settings.providers.account.switched.title", { provider: input.provider }), + description: language.t("settings.providers.account.switched.description", { account: input.account.label }), + }), + ) + .catch((error) => fail(formatServerError(error, language.t))) + + const remove = (input: { integrationID: string; provider: string; account: ProviderAccount; final: boolean }) => + sdk.api.credential + .remove({ credentialID: input.account.id }) + .then(refresh) + .then(() => (input.final ? undefined : settle(input.integrationID))) + .then(() => + showToast({ + variant: "success", + icon: "circle-check", + title: input.final + ? language.t("provider.disconnect.toast.disconnected.title", { provider: input.provider }) + : language.t("settings.providers.account.removed.title", { account: input.account.label }), + description: input.final + ? language.t("provider.disconnect.toast.disconnected.description", { provider: input.provider }) + : language.t("settings.providers.account.removed.description", { provider: input.provider }), + }), + ) + .catch((error) => fail(formatServerError(error, language.t))) + + const rename = (account: ProviderAccount, label: string) => + sdk.api.credential + .update({ credentialID: account.id, label }) + .then(refresh) + .then(() => true) + .catch((error) => { + fail(formatServerError(error, language.t)) + + return false + }) + + return { refresh, activate, remove, rename } +} diff --git a/packages/app/src/settings/providers/account-name.tsx b/packages/app/src/settings/providers/account-name.tsx new file mode 100644 index 00000000000..5f97423a324 --- /dev/null +++ b/packages/app/src/settings/providers/account-name.tsx @@ -0,0 +1,69 @@ +import { InlineInput } from "@opencode/ui/inline-input" +import { onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { useLanguage } from "@/runtime/i18n/language" + +export function AccountNameInput(props: { + value: string + class?: string + onSave: (label: string) => Promise + onClose: () => void +}) { + const language = useLanguage() + const [store, setStore] = createStore({ draft: props.value, saving: false, cancelled: false }) + let input: HTMLInputElement | undefined + + // Waits a frame so a closing menu's focus restore cannot pull focus back out of the field. + onMount(() => + requestAnimationFrame(() => { + input?.focus() + input?.select() + }), + ) + + const save = async () => { + const label = store.draft.trim() + + if (!label || label === props.value) return props.onClose() + setStore("saving", true) + + if (await props.onSave(label)) return props.onClose() + setStore("saving", false) + requestAnimationFrame(() => input?.focus()) + } + + return ( + setStore("draft", event.currentTarget.value)} + data-owns-escape + onKeyDown={(event) => { + event.stopPropagation() + + if (event.isComposing || event.keyCode === 229) return + + if (event.key === "Enter") { + event.preventDefault() + void save() + + return + } + + if (event.key !== "Escape") return + event.preventDefault() + setStore("cancelled", true) + props.onClose() + }} + // Like session title editing, clicking away keeps the typed name. + onBlur={() => { + if (!store.saving && !store.cancelled) void save() + }} + /> + ) +} diff --git a/packages/app/src/settings/providers/providers.tsx b/packages/app/src/settings/providers/providers.tsx index 49a8f8269fc..026a5cc832d 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 { IconButton } from "@opencode/ui/icon-button" import { Menu } from "@opencode/ui/menu" import { OpenCodeLogo } from "@/providers/opencode-logo" import { showToast } from "@/shell/notifications/toast" @@ -12,12 +13,14 @@ import { createEffect, createMemo, type Component, For, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { useLanguage } from "@/runtime/i18n/language" import { useServerSDK } from "@/runtime/server/client" -import { useData } from "@/runtime/server/current" +import { formatServerError } from "@/runtime/server/errors" import { CONSOLE_INTEGRATION, CONSOLE_PROVIDERS } from "@/providers/connect/controller" 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 { AccountNameInput } from "./account-name" +import { useProviderAccountActions } from "./account-actions" import "@/settings/settings.css" type ProviderSource = "env" | "api" | "account" | "config" | "custom" @@ -42,16 +45,20 @@ export const SettingsProviders: Component<{ const dialog = useDialog() const language = useLanguage() const serverSdk = useServerSDK() - const data = useData() const providers = useProviders(() => props.directory) const integrations = useIntegrations(() => props.directory) const providerConnect = useProviderConnectController() + const accountActions = useProviderAccountActions(() => props.directory) - const [state, setState] = createStore({ - disconnecting: {} as Record, + const [state, setState] = createStore<{ + disconnecting: Record + consoleExpanded: boolean + credentialID?: string + renaming?: string + held?: ProviderItem[] + }>({ + disconnecting: {}, consoleExpanded: false, - connecting: false, - credentialID: undefined as string | undefined, }) const updateDisconnecting = (ids: string[], status: "removing" | "removed" | "absent" | undefined) => @@ -69,7 +76,9 @@ export const SettingsProviders: Component<{ } const connect = (provider?: string) => { - setState("connecting", true) + // The list stays as it was while the dialog connects, so a new sign-in that briefly drops the + // Console workspace providers from the catalog can't flash rows behind the dialog. + setState("held", available()) providerConnect.select(provider) void dialog.show( () => ( @@ -88,22 +97,15 @@ export const SettingsProviders: Component<{ }} /> ), - () => { - setState("connecting", false) - const location = props.directory ? { directory: props.directory } : undefined - data.location.integration.invalidate(location) - data.location.provider.invalidate(location) - data.location.model.invalidate(location) - void Promise.all([ - data.location.integration.sync(location), - data.location.provider.sync(location), - data.location.model.sync(location), - ]).catch(() => undefined) - }, + () => void accountActions + .refresh() + .catch(() => undefined) + .finally(() => setState("held", undefined)), ) } const available = createMemo(() => { + if (state.held) return state.held const connected = providers.connected() const managedConsole = consoleProviderGroup(connected) @@ -112,21 +114,13 @@ export const SettingsProviders: Component<{ .find((item) => item.id === CONSOLE_INTEGRATION) ?.connections.some((connection) => connection.type === "credential" || connection.type === "env") - // Hides the free `opencode` row while a new Console grant is still loading its workspace providers. - const consoleTransition = - state.connecting && - CONSOLE_PROVIDERS.has(providerConnect.selected() ?? "") && - consoleConnected && - managedConsole === undefined - return connected .filter( (provider) => provider.id !== "opencode" || - (!consoleTransition && - (managedConsole !== undefined || - consoleConnected || - Object.values(provider.models).some((model) => model.cost.input > 0))), + managedConsole !== undefined || + consoleConnected || + Object.values(provider.models).some((model) => model.cost.input > 0), ) .toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go")) }) @@ -260,9 +254,9 @@ export const SettingsProviders: Component<{ description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }), }) }) - .catch((err: unknown) => { + .catch((err) => { updateDisconnecting(ids, undefined) - const message = err instanceof Error ? err.message : String(err) + const message = formatServerError(err, language.t) showToast({ title: language.t("common.requestFailed"), description: language.tDynamic("provider.disconnect.toast.failed.description", message, { provider: name }), @@ -270,130 +264,129 @@ 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)) + setState({ credentialID: account.id, held: available() }) + await accountActions.activate({ integrationID: provider.integrationID ?? provider.id, provider: providerName, account }) + setState({ credentialID: undefined, held: 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)) + setState({ credentialID: account.id, held: available() }) + await accountActions.remove({ + integrationID: provider.integrationID ?? provider.id, + provider: providerName, + account, + final: providerAccounts(integration(provider)).length === 1, + }) + setState({ credentialID: undefined, held: 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 + const renaming = () => accounts().find((account) => account.id === state.renaming) return ( - - - - + ) } diff --git a/packages/app/src/settings/settings.css b/packages/app/src/settings/settings.css index 794c53f29a4..682081af199 100644 --- a/packages/app/src/settings/settings.css +++ b/packages/app/src/settings/settings.css @@ -1037,9 +1037,15 @@ opacity: 1; } +.settings-provider-account-actions { + display: flex; + min-width: 0; + max-width: min(272px, 50%); + align-items: center; +} + .settings-provider-account-trigger { min-width: 0; - max-width: min(240px, 45%); } .settings-provider-account-trigger > span { @@ -1049,6 +1055,14 @@ white-space: nowrap; } +.settings-provider-account-input { + max-width: min(240px, 45%); + font-size: 13px; + font-weight: 440; + line-height: 20px; + color: var(--v2-text-text-base); +} + .settings-provider-account-menu[data-component="menu-v2-content"] { width: min(260px, calc(100vw - 32px)); overflow: visible; @@ -1059,12 +1073,6 @@ 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; @@ -1073,7 +1081,7 @@ } @container settings-panel (max-width: 520px) { - .settings-provider-account-trigger { + .settings-provider-account-actions { max-width: 100%; } } @@ -1305,7 +1313,11 @@ display: flex; min-width: 0; flex-direction: column; - gap: 12px; + gap: 8px; +} + +.provider-model-group[data-expanded]:not(:last-child) { + margin-block-end: 4px; } .settings-models-console-groups { @@ -1323,9 +1335,11 @@ } .provider-model-group-header { + position: relative; display: flex; min-width: 0; align-items: center; + border-radius: 8px; } .provider-model-group-trigger { @@ -1349,8 +1363,12 @@ outline-offset: -2px; } +.provider-model-group-header:has(:focus-visible) { + background-color: var(--v2-background-bg-layer-02); +} + @media (hover: hover) { - .provider-model-group-trigger:not(:disabled):hover { + .provider-model-group-header:hover { background-color: var(--v2-background-bg-layer-02); } } @@ -1393,7 +1411,7 @@ .provider-model-group-actions { position: relative; - z-index: 1; + z-index: 2; display: flex; flex-shrink: 0; align-items: center; @@ -1413,24 +1431,24 @@ border-top: 0.5px solid var(--v2-border-border-base); } -.provider-model-group:has(.provider-model-group-trigger:focus-visible) +.provider-model-group:has(.provider-model-group-header :focus-visible) .provider-model-group-models > [data-component="settings-list"] { border-top-color: transparent; } -.provider-model-group:has(.provider-model-group-trigger:focus-visible) { +.provider-model-group:has(.provider-model-group-header :focus-visible) { outline: 0.5px solid var(--v2-border-border-muted); outline-offset: -0.5px; } @media (hover: hover) { - .provider-model-group:has(.provider-model-group-trigger:hover) { + .provider-model-group:has(.provider-model-group-header:hover) { outline: 0.5px solid var(--v2-border-border-muted); outline-offset: -0.5px; } - .provider-model-group:has(.provider-model-group-trigger:hover) + .provider-model-group:has(.provider-model-group-header:hover) .provider-model-group-models > [data-component="settings-list"] { border-top-color: transparent; diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index 695f80b9c23..1442e2ef92f 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -117,6 +117,9 @@ function init() { const onKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return + + // A control that cancels its own edit on Escape, such as an inline rename, keeps the dialog open. + if (event.target instanceof Element && event.target.closest("[data-owns-escape]")) return dismiss() event.preventDefault() event.stopPropagation() diff --git a/packages/ui/src/data-display/badge/badge.css b/packages/ui/src/data-display/badge/badge.css index 962d3d0f176..dbdc62793a2 100644 --- a/packages/ui/src/data-display/badge/badge.css +++ b/packages/ui/src/data-display/badge/badge.css @@ -32,6 +32,11 @@ color: var(--v2-text-text-contrast); } +[data-component="tag"][data-variant="subtle"] { + border: 0; + background: var(--v2-background-bg-layer-03); +} + [data-component="tag"][data-appearance="standard"] { height: 18px; padding: 0 6px; diff --git a/packages/ui/src/data-display/badge/badge.stories.tsx b/packages/ui/src/data-display/badge/badge.stories.tsx index 633ee4a71f6..13b9343913e 100644 --- a/packages/ui/src/data-display/badge/badge.stories.tsx +++ b/packages/ui/src/data-display/badge/badge.stories.tsx @@ -9,7 +9,7 @@ Use alongside headings or lists for quick metadata. ### API - Accepts standard span props. - Optional: \`appearance\` is \`compact\` (default) or \`standard\`. -- Optional: \`variant\` is \`neutral\` (default) or \`accent\`. +- Optional: \`variant\` is \`neutral\` (default), \`accent\`, or \`subtle\` (borderless, for counts). - Optional: \`data-high-contrast\` attribute for stronger border contrast. ### Variants and states @@ -55,6 +55,15 @@ export const Appearances = { ), } +export const Subtle = { + render: () => ( +
+ 1 + 12 +
+ ), +} + export const HighContrast = { render: () => (
diff --git a/packages/ui/src/data-display/badge/badge.tsx b/packages/ui/src/data-display/badge/badge.tsx index 2fd8bf5c1f3..459008026a0 100644 --- a/packages/ui/src/data-display/badge/badge.tsx +++ b/packages/ui/src/data-display/badge/badge.tsx @@ -3,7 +3,7 @@ import "./badge.css" export interface BadgeProps extends ComponentProps<"span"> { appearance?: "standard" | "compact" - variant?: "neutral" | "accent" + variant?: "neutral" | "accent" | "subtle" } export function Badge(props: BadgeProps) {