mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 20:21:39 +03:00
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:
committed by
GitHub
Unverified
parent
293a5bab9a
commit
de417edf24
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user