feat(desktop): manage provider accounts and restore Console connect intro (#54101)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
usrnk1
2026-10-14 01:49:06 +02:00
committed by GitHub
Unverified
parent 293a5bab9a
commit de417edf24
18 changed files with 1237 additions and 266 deletions
@@ -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<typeof model>) => ({
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",
@@ -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)
+13
View File
@@ -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 },
+33 -8
View File
@@ -44,7 +44,11 @@ export type MockFileContent =
export interface MockServerConfig {
server?: string
provider: Resolvable<MockProviderCatalog>
integrations?: unknown[]
integrations?: Resolvable<unknown[]>
// 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<unknown[]>
skills?: Resolvable<unknown[]>
// 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) }),
@@ -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<boolean>
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 =
+267 -84
View File
@@ -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) => (
<ProviderConnection
provider={provider}
accounts={state.picked}
directory={props.directory}
defaultLocation={props.defaultLocation}
onBack={reset}
@@ -137,8 +145,7 @@ export const DialogConnectProvider: Component<{
onConnected={(methodID) => {
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
>
<ProviderModelIcon provider={provider} class="shrink-0 text-v2-icon-icon-base" />
<span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
<Show when={CONSOLE_PROVIDERS.has(provider.id)}>
<Show when={accountCount(provider) > 0}>
<Badge
variant="subtle"
class="shrink-0"
aria-label={language.plural("dialog.provider.accounts.count", accountCount(provider), {
count: accountCount(provider),
})}
>
{accountCount(provider)}
</Badge>
</Show>
<Show when={CONSOLE_PROVIDERS.has(provider.id) && accountCount(provider) === 0}>
<span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
{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<string, boolean>
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 (
<div class="flex flex-col items-start gap-3">
<div class="flex flex-col items-start" classList={{ "gap-3": !isConsole, "gap-4": isConsole }}>
<div class="flex items-start gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
<Icon name="circle-ban-sign" size="small" class="mt-0.5 shrink-0 text-v2-state-fg-danger" />
<span role="alert">
@@ -961,8 +989,8 @@ function ProviderConnection(props: {
: language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}
</span>
</div>
<Button variant="neutral" onClick={() => void controller.auth.retry()}>
{language.t("common.retry")}
<Button variant={isConsole ? undefined : "neutral"} onClick={() => void controller.auth.retry()}>
{language.t(isConsole ? "provider.connect.console.retry" : "common.retry")}
</Button>
</div>
)
@@ -1001,21 +1029,15 @@ function ProviderConnection(props: {
}
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<Show
when={isConsole}
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
>
<div>
{language.t("provider.connect.console.apiKey.description")}{" "}
<ExternalLink
href="https://opencode.ai/console"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.console.apiKey.link")}
</ExternalLink>
</div>
</Show>
<div
class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
classList={{ "pb-1": isConsole }}
>
<p>
{isConsole
? language.t("provider.connect.console.serviceKeyDescription")
: language.t("provider.connect.apiKey.description", { provider: provider().name })}
</p>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-2 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
@@ -1040,7 +1062,12 @@ function ProviderConnection(props: {
</div>
)}
</Show>
<Button type="submit" variant="contrast" data-action="provider-connect-submit">
<Button
type="submit"
variant="contrast"
class={isConsole ? "!px-3" : undefined}
data-action="provider-connect-submit"
>
{language.t("common.continue")}
</Button>
</form>
@@ -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 (
<div data-component="console-service-account" class="flex h-7 items-center gap-1 px-3 pt-5 text-[13px]">
<span class="text-v2-text-text-faint">{language.t("provider.connect.console.serviceAccount")}</span>
<Button
variant="ghost-muted"
data-action="provider-connect-api-key"
onClick={() => void controller.auth.select(keyIndex())}
>
{language.t("provider.connect.console.useApiKey")}
<div data-component="provider-accounts" class="flex flex-col gap-3">
<section class="flex flex-col">
<div class="px-3 pb-2 text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-muted">
{language.t("dialog.provider.accounts.connected")}
</div>
<div role="radiogroup" aria-label={language.t("dialog.provider.accounts.connected")} class="flex flex-col">
<For each={accounts()}>
{(account) => (
<Show
when={state.confirming !== account.id}
fallback={
<div class="flex min-h-9 items-center gap-2 rounded-md px-3 text-[13px] leading-5 tracking-[-0.04px]">
<span role="alert" class="min-w-0 flex-1 truncate font-[440] text-v2-text-text-base">
{language.t("dialog.provider.accounts.removeConfirm", { account: account.label })}
</span>
<Button variant="ghost-muted" onClick={() => setState("confirming", undefined)}>
{language.t("common.cancel")}
</Button>
<Button variant="neutral" onClick={() => void removeAccount(account)}>
{language.t("dialog.provider.accounts.remove")}
</Button>
</div>
}
>
<div class="group relative flex min-h-9 items-center rounded-md hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover">
<Show
when={state.renaming !== account.id}
fallback={
<div class="flex min-h-9 min-w-0 flex-1 items-center px-3">
<AccountNameInput
value={account.label}
class="-ms-1 max-w-full text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base"
onSave={(label) => accountActions.rename(account, label)}
onClose={() => setState("renaming", undefined)}
/>
</div>
}
>
<button
type="button"
role="radio"
aria-checked={activeAccount() === account.id}
class="flex min-h-9 min-w-0 flex-1 items-center gap-2 px-3 text-left text-[13px] leading-5 tracking-[-0.04px] focus-visible:outline-none"
disabled={state.switching !== undefined}
onClick={() => void activateAccount(account)}
>
<span class="min-w-0 truncate font-[440] text-v2-text-text-base">{account.label}</span>
<Show when={activeAccount() === account.id}>
<Icon name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
</Show>
<div class="flex shrink-0 items-center pe-1.5">
<Menu gutter={4} modal={false} placement="bottom-end">
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
size="small"
icon={<Icon name="outline-dots" />}
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. */}
<Menu.Portal
ref={(el) => {
el.dataset.reactAriaTopLayer = "true"
}}
>
<Menu.Content onCloseAutoFocus={(event) => state.renaming && event.preventDefault()}>
<Menu.Item onSelect={() => setState({ renaming: account.id, confirming: undefined })}>
{language.t("common.rename")}
</Menu.Item>
<Menu.Item onSelect={() => setState({ confirming: account.id, renaming: undefined })}>
{language.t("dialog.provider.accounts.remove")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
</div>
</Show>
)}
</For>
</div>
</section>
<Button class="-mt-1 ms-2.5 self-start !px-3" disabled={state.switching !== undefined} onClick={addAccount}>
{language.t("dialog.provider.accounts.add")}
</Button>
</div>
)
}
function ConsoleIntro() {
return (
<div class="flex flex-col items-start gap-5 px-3">
<p class="text-[13px] leading-5 text-v2-text-text-muted">{language.t("provider.connect.console.intro")}</p>
<Button
size="large"
class="!px-3"
variant="contrast"
disabled={oauthIndex() === -1 || consoleStarting()}
aria-busy={consoleStarting()}
onClick={() => void controller.auth.select(oauthIndex())}
>
<Show when={consoleStarting()}>
<span class="absolute inset-0 flex items-center justify-center gap-1.5">
<Loader />
<span>{language.t("provider.connect.console.openingBrowser")}</span>
</span>
</Show>
<span aria-hidden={consoleStarting() ? "true" : undefined} classList={{ "opacity-0": consoleStarting() }}>
{language.t("provider.connect.console.continue")}
</span>
</Button>
<Show when={keyIndex() !== -1}>
<div data-component="console-service-account" class="flex h-7 items-center gap-1 text-[13px]">
<span class="text-v2-text-text-faint">{language.t("provider.connect.console.serviceAccount")}</span>
<Button
variant="ghost-muted"
data-action="provider-connect-api-key"
disabled={consoleStarting()}
onClick={() => void controller.auth.select(keyIndex())}
>
{language.t("provider.connect.console.useApiKey")}
</Button>
</div>
</Show>
</div>
)
}
function ConsoleNoModels() {
return (
<div role="status" class="flex flex-col items-start gap-5 px-3 text-[13px] leading-5 text-v2-text-text-muted">
@@ -1352,7 +1527,17 @@ function ProviderConnection(props: {
<div class="text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base">
<DialogTitle>
<Switch>
<Match when={consoleSignIn()}>{language.t("provider.connect.console.title")}</Match>
<Match when={showAccounts()}>{provider().name}</Match>
<Match when={isConsole && controller.auth.state() === "error"}>
{language.t("provider.connect.opencode.errorTitle")}
</Match>
<Match when={consoleSignIn()}>
{language.t(
props.provider === "opencode-go"
? "provider.connect.console.titleGo"
: "provider.connect.console.title",
)}
</Match>
<Match
when={
props.provider === "anthropic" && controller.currentMethod()?.label?.toLowerCase().includes("max")
@@ -1360,6 +1545,9 @@ function ProviderConnection(props: {
>
{language.t("provider.connect.title.anthropicProMax")}
</Match>
<Match when={props.provider === "opencode"}>
{language.t("provider.connect.title", { provider: language.t("provider.connect.opencode.name") })}
</Match>
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
</Switch>
</DialogTitle>
@@ -1390,6 +1578,12 @@ function ProviderConnection(props: {
/>
</div>
</Match>
<Match when={showAccounts() && controller.methodIndex() === undefined}>
<AccountsView />
</Match>
<Match when={consoleIntro()}>
<ConsoleIntro />
</Match>
<Match when={controller.busy()}>
<div class="px-3">
<StatusRow>{language.t("provider.connect.status.inProgress")}</StatusRow>
@@ -1416,17 +1610,6 @@ function ProviderConnection(props: {
<OAuthAutoView />
</Match>
</Switch>
<Show
when={
isConsole &&
!controller.loading() &&
!state.noModels &&
controller.currentMethod()?.type !== "key" &&
keyIndex() !== -1
}
>
<ConsoleApiKeySwitch />
</Show>
</div>
</div>
</Show>
@@ -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);
}
}
@@ -58,6 +58,7 @@ function SelectorStory(props: { plan: boolean }) {
{models.find((model) => model.id === current())?.name}
</Button>
)}
all={() => models}
models={(search) => models.filter((model) => model.name.toLowerCase().includes(search.toLowerCase()))}
groups={(items) => [{ category: "openai", items }]}
current={`openai:${current()}`}
@@ -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={
<ModelSelectorPopoverView
trigger={props.trigger}
all={controller.all}
models={controller.models}
groups={controller.groups}
current={controller.current()}
@@ -494,6 +496,7 @@ function createModelSelectorController(input: {
export function ModelSelectorPopoverView(props: {
trigger: ModelSelectorTrigger
all: () => 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: {
<For each={groups()}>
{(group) => (
<Menu.Group>
<Menu.GroupLabel class="gap-2 px-3">
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
<Menu.GroupLabel
class="gap-1.5 pe-3"
style={{ "padding-inline-start": group.items[0].provider.id === "opencode" ? "10px" : "12px" }}
>
<ProviderModelIcon provider={group.items[0].provider} class="shrink-0" />
<Show
when={managedIDs().has(group.category) ? managed() : undefined}
fallback={<span class="min-w-0 truncate">{group.items[0].provider.name}</span>}
>
{(workspace) => (
<>
<span class="min-w-0 truncate">
{consoleProviderName(workspace(), group.items[0].provider.name)}
</span>
<Show when={direct().has(canonical(group.items[0].provider))}>
<Badge class="shrink-0">{workspace().workspace}</Badge>
</Show>
</>
)}
</Show>
</Menu.GroupLabel>
<Menu.RadioGroup value={props.current}>
<For each={group.items}>
+14 -5
View File
@@ -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",
@@ -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 }
}
@@ -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<boolean>
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 (
<InlineInput
ref={input}
aria-label={language.t("settings.providers.account.name")}
dir="auto"
value={store.draft}
disabled={store.saving}
class={`field-sizing-content rounded-[6px] px-1 py-1 ${props.class ?? ""}`}
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
onInput={(event) => 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()
}}
/>
)
}
+133 -140
View File
@@ -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<string, "removing" | "removed" | "absent" | undefined>,
const [state, setState] = createStore<{
disconnecting: Record<string, "removing" | "removed" | "absent" | undefined>
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 (
<Menu placement="bottom-end" gutter={6}>
<Menu.Trigger
as={Button}
size="normal"
variant="ghost-muted"
class="settings-provider-account-trigger"
aria-label={language.t("settings.providers.account.manage", { provider: name() })}
>
<span>{active()?.label}</span>
<Icon name="chevron-down" size="small" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="settings-provider-account-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
<Menu.Group>
<Menu.GroupLabel>{language.t("settings.providers.account.group")}</Menu.GroupLabel>
<Menu.RadioGroup
class="settings-provider-account-list"
value={active()?.id}
onChange={(credentialID) => {
const account = accounts().find((item) => item.id === credentialID)
<Show
when={renaming()}
fallback={
<AccountMenuTrigger provider={menuProps.provider} name={name()} accounts={accounts()} active={active()} />
}
>
{(account) => (
<AccountNameInput
value={account().label}
class="settings-provider-account-input"
onSave={(label) => accountActions.rename(account(), label)}
onClose={() => setState("renaming", undefined)}
/>
)}
</Show>
)
}
if (account) void activate(menuProps.provider, name(), account)
function AccountMenuTrigger(menuProps: {
provider: ProviderItem
name: string
accounts: ProviderAccount[]
active: ProviderAccount | undefined
}) {
const accounts = () => menuProps.accounts
const active = () => menuProps.active
const name = () => menuProps.name
return (
<div class="settings-provider-account-actions">
<Menu placement="bottom-end" gutter={6}>
<Menu.Trigger
as={Button}
size="normal"
variant="ghost-muted"
class="settings-provider-account-trigger"
aria-label={language.t("settings.providers.account.manage", { provider: name() })}
>
<span>{active()?.label}</span>
<Icon name="chevron-down" size="small" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="settings-provider-account-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
<Menu.Group>
<Menu.GroupLabel>{language.t("settings.providers.account.group")}</Menu.GroupLabel>
<Menu.RadioGroup
class="settings-provider-account-list"
value={active()?.id}
onChange={(credentialID) => {
const account = accounts().find((item) => item.id === credentialID)
if (account) void activate(menuProps.provider, name(), account)
}}
>
<For each={accounts()}>
{(account) => (
<Menu.RadioItem value={account.id} closeOnSelect disabled={state.credentialID !== undefined}>
<span class="settings-provider-account-label">{account.label}</span>
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Menu.Group>
<Menu.Separator />
<Menu.Item disabled={state.credentialID !== undefined} onSelect={() => connect(menuProps.provider.id)}>
<Icon name="plus" size="small" class="shrink-0" />
{language.t("settings.providers.account.add")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
<Menu placement="bottom-end" gutter={6}>
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
size="large"
icon={<Icon name="outline-dots" />}
disabled={state.credentialID !== undefined || !active()}
aria-label={language.t("dialog.provider.accounts.options", { account: active()?.label ?? "" })}
/>
<Menu.Portal>
<Menu.Content onCloseAutoFocus={(event) => state.renaming && event.preventDefault()}>
<Menu.Item onSelect={() => setState("renaming", active()?.id)}>
{language.t("common.rename")}
</Menu.Item>
<Menu.Item
onSelect={() => {
const account = active()
if (account) void remove(menuProps.provider, name(), account)
}}
>
<For each={accounts()}>
{(account) => (
<Menu.RadioItem value={account.id} closeOnSelect disabled={state.credentialID !== undefined}>
<span class="settings-provider-account-label">{account.label}</span>
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Menu.Group>
<Menu.Separator />
<Menu.Item disabled={state.credentialID !== undefined} onSelect={() => connect(menuProps.provider.id)}>
{language.t("settings.providers.account.add")}
</Menu.Item>
<Menu.Sub placement="left-start">
<Menu.SubTrigger disabled={state.credentialID !== undefined || accounts().length === 0}>
{language.t("settings.providers.account.remove")}
</Menu.SubTrigger>
<Menu.SubContent class="settings-provider-account-submenu">
<For each={accounts()}>
{(account) => (
<Menu.Item
badge={account.id === active()?.id ? language.t("settings.providers.account.active") : undefined}
onSelect={() => void remove(menuProps.provider, name(), account)}
>
<span class="settings-provider-account-label">{account.label}</span>
</Menu.Item>
)}
</For>
</Menu.SubContent>
</Menu.Sub>
</Menu.Content>
</Menu.Portal>
</Menu>
{language.t("dialog.provider.accounts.remove")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
)
}
+33 -15
View File
@@ -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;
+3
View File
@@ -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()
@@ -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;
@@ -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: () => (
<div style={{ display: "flex", gap: "8px", "align-items": "center" }}>
<Badge variant="subtle">1</Badge>
<Badge variant="subtle">12</Badge>
</div>
),
}
export const HighContrast = {
render: () => (
<div style={{ display: "flex", gap: "8px", "align-items": "center" }}>
+1 -1
View File
@@ -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) {