Compare commits

...
35 changed files with 2122 additions and 244 deletions
@@ -0,0 +1,491 @@
import { expect, test } from "@playwright/test"
import type { Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/console-auth-project"
const location = { directory, project: { id: "proj_console", directory, canonical: directory } }
const provider = {
id: "opencode",
integrationID: "opencode",
name: "Anomaly / OpenCode",
activation: "enabled",
package: "@ai-sdk/openai-compatible",
}
const secondProvider = {
...provider,
id: "console-google",
name: "Anomaly / Google",
package: "@ai-sdk/google",
}
const model = {
id: "sonnet",
modelID: "sonnet",
providerID: provider.id,
name: "Console Sonnet",
enabled: true,
status: "active",
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [],
cost: [],
time: { released: 1700000000000 },
limit: { context: 200000, output: 32000 },
}
const models = [
model,
...Array.from({ length: 18 }, (_, index) => ({
...model,
id: `model-${index + 2}`,
modelID: `model-${index + 2}`,
name: `Console Model ${index + 2}`,
})),
{ ...model, id: "gemini", modelID: "gemini", providerID: secondProvider.id, name: "Console Gemini" },
]
const integration = {
id: "opencode",
name: "OpenCode",
connections: [],
methods: [
{ id: "device", type: "oauth", label: "OpenCode Console account" },
{ type: "key", label: "API key (service account)" },
],
}
async function fixture(
page: Page,
remote = false,
options: {
draft?: boolean
browserFailed?: boolean
slowStart?: Promise<void>
existingConnection?: boolean
singleProvider?: boolean
} = {},
) {
const state = {
status: "pending",
starts: 0,
cancelled: [] as string[],
models: true,
modelError: false,
statusError: false,
}
const server = remote ? "http://production.example:4096" : undefined
const currentIntegration = {
...integration,
connections: options.existingConnection ? [{ type: "env", name: "OPENCODE_API_KEY" }] : [],
}
await mockOpenCodeServer(page, {
server,
directory,
provider: [],
sessions: [],
project: {
id: "proj_console",
canonical: directory,
name: "Console test",
time: { created: 1700000000000, updated: 1700000000000 },
},
pageMessages: () => ({ items: [] }),
})
await page
.context()
.route("https://console.example/**", (route) =>
route.fulfill({ contentType: "text/html", body: "<title>Console fixture</title><p>Authorize access</p>" }),
)
await page.route("**/api/integration**", async (route) => {
const request = route.request()
const path = new URL(request.url()).pathname
if (request.method() === "OPTIONS") return route.fallback()
const headers = { "access-control-allow-origin": "*" }
const json = (data: unknown) => route.fulfill({ headers, json: { location, data } })
if (path === "/api/integration") return json([currentIntegration])
if (path === "/api/integration/opencode") return json(currentIntegration)
if (path === "/api/integration/opencode/connect/oauth") {
expect(request.postDataJSON()).toEqual({ methodID: "device" })
state.starts++
if (options.slowStart) await options.slowStart
return json({
attemptID: `con_${state.starts}`,
mode: "auto",
instructions: "Confirmation code: TFXS-STXG",
url: "https://console.example/device?user_code=TFXS-STXG&client_id=opencode-cli",
time: { created: Date.now(), expires: Date.now() + 60000 },
})
}
if (path.includes("/connect/oauth/con_")) {
if (request.method() === "DELETE") {
state.cancelled.push(path.split("/").pop()!)
return route.fulfill({ status: 204, headers })
}
if (state.statusError) return route.fulfill({ status: 503, headers })
return json({
status: state.status,
...(state.status === "failed" ? { message: "Device authorization failed: access_denied" } : {}),
time: { created: 0, expires: Date.now() + 60000 },
})
}
return route.fallback()
})
await page.route("**/api/provider**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
headers: { "access-control-allow-origin": "*" },
json: {
location,
data: state.status === "complete" ? [provider, ...(options.singleProvider ? [] : [secondProvider])] : [],
},
})
})
await page.route("**/api/model**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
if (state.modelError) return route.fulfill({ status: 503, headers: { "access-control-allow-origin": "*" } })
const available = state.status === "complete" && state.models
return route.fulfill({
headers: { "access-control-allow-origin": "*" },
json: {
location,
data: new URL(route.request().url()).pathname.endsWith("/default")
? available
? model
: null
: available
? options.singleProvider
? models.filter((model) => model.providerID === provider.id)
: models
: [],
},
})
})
await page.addInitScript(
({ directory, server }) => {
if (server) localStorage.setItem("opencode.settings.dat:defaultServerUrl", server)
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
list: server ? [{ type: "http", displayName: "Production server", http: { url: server } }] : [],
projects: { [server ?? "local"]: [{ worktree: directory, expanded: true }] },
}),
)
},
{ directory, server },
)
const params = new URLSearchParams()
if (server) params.set("server", server)
if (options.browserFailed) params.set("browserFailed", "1")
await page.goto(`/e2e/desktop/index.html?${params}`)
const dialog = page.locator('[data-component="dialog-v2"]').getByRole("dialog")
if (options.draft) {
await page.keyboard.press("Control+t")
const composer = page.locator('[data-component="composer-editor"]')
await expect(composer).toBeEditable()
await composer.fill("Keep this draft throughout sign-in")
await expect(page.locator('[data-component="provider-setup"]')).toBeVisible()
await expect(page.locator('[data-component="new-session-tip"]')).toContainText("Connect to 75+ providers")
await page.getByRole("button", { name: "Continue with OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
return { state, dialog }
}
await page.getByRole("button", { name: "Settings", exact: true }).click()
await page.getByRole("tab", { name: "Providers", exact: true }).click()
// Use the picker so this also exercises the existing Settings entry point.
await page.getByRole("button", { name: "Show more providers", exact: true }).click()
await page
.getByRole("dialog")
.getByRole("button", { name: /^OpenCode / })
.click()
await expect(dialog.getByRole("button", { name: "Continue with OpenCode Console" })).toBeEnabled()
return { state, dialog }
}
test("Console account is primary and the code is displayed without a copy-code step", async ({ page }) => {
const { state, dialog } = await fixture(page)
await expect(dialog.getByRole("heading", { name: "Connect OpenCode Console", exact: true })).toBeVisible()
await expect(dialog.getByText("Service account?", { exact: true })).toBeVisible()
await expect(dialog.getByRole("button", { name: "Use API key", exact: true })).toBeVisible()
const shell = await dialog.boundingBox()
const back = await dialog.getByRole("button", { name: "Navigate back" }).boundingBox()
const heading = await dialog.getByRole("heading", { name: "Connect OpenCode Console" }).boundingBox()
const logo = await dialog.locator('[data-component="opencode-logo"]').boundingBox()
const description = await dialog
.getByText("Sign in once to use the models available through your OpenCode account.")
.boundingBox()
const primary = await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).boundingBox()
const service = await dialog.locator('[data-component="console-service-account"]').boundingBox()
if (!shell || !back || !heading || !logo || !description || !primary || !service)
throw new Error("Missing dialog layout")
expect(shell.height).toBe(512)
expect(back.x - shell.x).toBe(20)
expect(back.y - shell.y).toBe(16)
expect(heading.y - (back.y + back.height)).toBe(12)
expect(logo.y + logo.height / 2).toBe(heading.y + heading.height / 2)
expect(description.y - (heading.y + heading.height)).toBe(24)
expect(primary.y - (description.y + description.height)).toBe(20)
expect(service.y - (primary.y + primary.height)).toBe(20)
await page.screenshot({ path: test.info().outputPath("connect-console-light.png") })
const popup = page.waitForEvent("popup")
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
const consolePage = await popup
await expect(consolePage).toHaveURL(/user_code=TFXS-STXG/)
await expect(consolePage).toHaveURL(/client_id=opencode-desktop/)
await expect(consolePage).toHaveURL(/return_window=console-auth-fixture/)
await expect(
dialog.getByText("Continue in your browser. Confirm the code shown there matches the one below."),
).toBeVisible()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await expect(dialog.getByRole("button", { name: "Copy sign-in link" })).toBeVisible()
const authHeading = await dialog.getByRole("heading", { name: "Connect OpenCode Console account" }).boundingBox()
const authDescription = await dialog
.getByText("Continue in your browser. Confirm the code shown there matches the one below.")
.boundingBox()
const label = await dialog.getByText("Device code", { exact: true }).boundingBox()
const code = await dialog.getByRole("group", { name: "Device code: TFXS-STXG" }).boundingBox()
const waiting = await dialog.getByRole("status").boundingBox()
const fallback = await dialog.locator('[data-component="console-browser-fallback"]').boundingBox()
const authShell = await dialog.boundingBox()
if (!authHeading || !authDescription || !label || !code || !waiting || !fallback || !authShell)
throw new Error("Missing authorization layout")
expect(authDescription.y - (authHeading.y + authHeading.height)).toBe(24)
expect(label.y - (authDescription.y + authDescription.height)).toBe(20)
expect(code.y - (label.y + label.height)).toBe(8)
expect(code.height).toBe(48)
expect(waiting.y - (code.y + code.height)).toBe(8)
expect(fallback.y - (waiting.y + waiting.height)).toBe(20)
expect(authShell.height).toBeLessThan(512)
expect(authShell.y + authShell.height - (fallback.y + fallback.height)).toBe(16)
await page.screenshot({ path: test.info().outputPath("console-auth-light.png") })
await page.emulateMedia({ colorScheme: "dark" })
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
await page.screenshot({ path: test.info().outputPath("console-auth-dark.png") })
state.status = "complete"
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
const list = dialog.getByRole("radiogroup", { name: "Models available from OpenCode" })
await expect(dialog.getByRole("button", { name: "Anomaly / OpenCode", exact: true })).toBeVisible()
await expect(dialog.getByRole("button", { name: "Anomaly / Google", exact: true })).toBeVisible()
await expect(list.getByRole("radio")).toHaveCount(models.length)
await page.mouse.move(0, 0)
const first = list.getByRole("radio", { name: "Console Sonnet" })
await expect(first).toBeChecked()
await expect(first).toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
await expect(dialog.locator('[data-component="settings-list"]')).toHaveCount(2)
await expect(first.locator('[data-slot="settings-row-title"]')).toHaveCSS("font-weight", "440")
await expect(first).toHaveCSS("border-radius", "0px")
const providerHeading = dialog.locator(".settings-models-group-header").filter({ hasText: "Anomaly / OpenCode" })
await expect(providerHeading).toHaveCSS("position", "sticky")
await expect(providerHeading).toHaveCSS("padding-bottom", "0px")
const google = dialog.getByRole("button", { name: "Anomaly / Google", exact: true })
const googleHeading = dialog.locator(".settings-models-group-header").filter({ hasText: "Anomaly / Google" })
await google.click()
await expect(googleHeading).toHaveCSS("padding-bottom", "8px")
await google.click()
await expect(googleHeading).toHaveCSS("padding-bottom", "0px")
await expect(dialog.locator('[data-slot="dialog-header"]')).toHaveCSS("padding-top", "20px")
const hovered = list.getByRole("radio", { name: "Console Model 3" })
await hovered.hover()
await expect(hovered).toHaveCSS("border-bottom-color", "rgba(0, 0, 0, 0)")
await expect(list.getByRole("radio", { name: "Console Model 2" })).toHaveCSS(
"border-bottom-color",
"rgba(0, 0, 0, 0)",
)
await page.screenshot({ path: test.info().outputPath("first-provider-models-dark.png") })
await list.getByRole("radio", { name: "Console Model 2" }).click()
await expect(list.getByRole("radio", { name: "Console Model 2" })).toBeChecked()
const scroll = dialog.locator('[data-component="first-provider-model-scroll"]')
const footer = dialog.locator('[data-component="first-provider-model-footer"]')
const footerBefore = await footer.boundingBox()
expect(await scroll.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
await scroll.evaluate((element) => element.scrollTo({ top: element.scrollHeight }))
await expect(list.getByRole("radio", { name: models.at(-1)!.name })).toBeInViewport()
expect(await footer.boundingBox()).toEqual(footerBefore)
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-action="composer-model"]')).toContainText("Console Model 2")
expect(state.starts).toBe(1)
expect(state.cancelled).toEqual([])
})
test("Manage models opens the Models settings page", async ({ page }) => {
const { state, dialog } = await fixture(page)
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.status = "complete"
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
await dialog.getByRole("button", { name: "Manage models", exact: true }).click()
await expect(dialog).toBeHidden()
await expect(page.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
})
test("a single connected provider has a non-collapsible heading", async ({ page }) => {
const { state, dialog } = await fixture(page, false, { singleProvider: true })
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.status = "complete"
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
await expect(dialog.getByRole("button", { name: "Anomaly / OpenCode", exact: true })).toHaveCount(0)
await expect(dialog.getByText("Anomaly / OpenCode", { exact: true })).toBeVisible()
})
test("model choice is skipped after a provider has already been connected", async ({ page }) => {
const { state, dialog } = await fixture(page, false, { existingConnection: true })
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.status = "complete"
await expect(dialog).toBeHidden()
await expect(page.getByRole("tab", { name: "Providers", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.getByText("OpenCode Console connected", { exact: true })).toBeVisible()
})
test("service-account API key form matches the Console dialog layout", async ({ page }) => {
const { dialog } = await fixture(page)
const initialShell = await dialog.boundingBox()
await dialog.getByRole("button", { name: "Use API key", exact: true }).click()
await expect(dialog.getByRole("heading", { name: "Connect OpenCode Console", exact: true })).toBeVisible()
const description = dialog.getByText("Connect using a service-account API key from OpenCode Console.")
const label = dialog.locator('[data-component="provider-api-key-label"]')
const input = dialog.getByLabel("OpenCode Console API key", { exact: true })
const button = dialog.getByRole("button", { name: "Continue", exact: true })
await expect(input).toBeFocused()
const shell = await dialog.boundingBox()
const heading = await dialog.getByRole("heading", { name: "Connect OpenCode Console" }).boundingBox()
const descriptionBox = await description.boundingBox()
const labelBox = await label.boundingBox()
const fieldBox = await input.locator("..").locator("..").boundingBox()
const buttonBox = await button.boundingBox()
if (!shell || !heading || !descriptionBox || !labelBox || !fieldBox || !buttonBox)
throw new Error("Missing API key dialog layout")
if (!initialShell) throw new Error("Missing initial Console dialog layout")
expect(shell.height).toBe(512)
expect(shell.height).toBe(initialShell.height)
expect(descriptionBox.y - (heading.y + heading.height)).toBe(24)
expect(labelBox.y - (descriptionBox.y + descriptionBox.height)).toBe(20)
expect(fieldBox.y - (labelBox.y + labelBox.height)).toBe(8)
expect(buttonBox.y - (fieldBox.y + fieldBox.height)).toBe(20)
await page.screenshot({ path: test.info().outputPath("console-api-key-light.png") })
})
test("setup preserves the draft and Continue restores composer focus", async ({ page }) => {
const { state, dialog } = await fixture(page, false, { draft: true })
state.status = "complete"
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
await dialog.getByRole("button", { name: "Continue", exact: true }).click()
const composer = page.locator('[data-component="composer-editor"]')
await expect(composer).toHaveText("Keep this draft throughout sign-in")
await expect(composer).toBeFocused()
await expect(page.locator('[data-action="composer-model"]')).toContainText("Console Sonnet")
await expect(page.locator('[data-component="provider-setup"]')).toBeHidden()
})
test("catalog refresh failure retries without asking for authorization again", async ({ page }) => {
const { state, dialog } = await fixture(page)
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.modelError = true
state.status = "complete"
await expect(dialog.getByRole("alert")).toContainText("Your account is connected, but we couldn't load your models")
state.modelError = false
await dialog.getByRole("button", { name: "Try again", exact: true }).click()
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
expect(state.starts).toBe(1)
})
test("status request failure resumes the existing attempt", async ({ page }) => {
const { state, dialog } = await fixture(page)
state.statusError = true
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
await expect(dialog.getByRole("alert")).toBeVisible()
state.statusError = false
state.status = "complete"
await dialog.getByRole("button", { name: "Try again", exact: true }).click()
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
expect(state.starts).toBe(1)
expect(state.cancelled).toEqual([])
})
test("closing during authorization startup cancels the late server attempt", async ({ page }) => {
const start = Promise.withResolvers<void>()
const { state, dialog } = await fixture(page, false, { slowStart: start.promise })
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
await expect.poll(() => state.starts).toBe(1)
await dialog.getByRole("button", { name: "Close", exact: true }).click()
await expect(dialog).toBeHidden()
start.resolve()
await expect.poll(() => state.cancelled).toEqual(["con_1"])
})
test("authorization startup stays on the Continue button until the device code is ready", async ({ page }) => {
const start = Promise.withResolvers<void>()
const { dialog } = await fixture(page, false, { slowStart: start.promise })
const button = dialog.getByRole("button", { name: "Continue with OpenCode Console" })
await button.click()
await expect(dialog.getByRole("button", { name: "Opening browser…" })).toHaveAttribute("aria-busy", "true")
await expect(dialog.getByRole("heading", { name: "Connect OpenCode Console" })).toBeVisible()
await expect(dialog.getByRole("group", { name: /Device code/ })).toHaveCount(0)
start.resolve()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
})
test("browser failure offers a copyable sign-in link in a narrow RTL window", async ({ page, context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"])
const { dialog } = await fixture(page, false, { browserFailed: true })
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
await expect(dialog.getByText(/We couldn't open your browser/)).toBeVisible()
await page.setViewportSize({ width: 380, height: 650 })
await page.evaluate(() => {
document.documentElement.dir = "rtl"
})
const code = dialog.getByRole("group", { name: "Device code: TFXS-STXG" })
await expect(code).toHaveCSS("direction", "ltr")
await expect(code).toBeInViewport()
await dialog.getByRole("button", { name: "Copy sign-in link" }).click()
await expect(dialog.getByRole("button", { name: "Sign-in link copied" })).toBeVisible()
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(
"https://console.example/device?user_code=TFXS-STXG&client_id=opencode-desktop&return_window=console-auth-fixture",
)
await expect(dialog.getByRole("button", { name: "Open Console again" })).toBeInViewport()
await page.screenshot({ path: test.info().outputPath("console-auth-narrow-rtl.png") })
})
test("cancel releases the server attempt and retrying expiration creates a new attempt", async ({ page }) => {
const { state, dialog } = await fixture(page)
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.status = "expired"
await expect(dialog.getByRole("alert")).toContainText("has expired")
state.status = "pending"
await dialog.getByRole("button", { name: "Try again", exact: true }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
await expect.poll(() => state.starts).toBe(2)
await dialog.getByRole("button", { name: "Close", exact: true }).click()
await expect.poll(() => state.cancelled).toEqual(["con_1", "con_2"])
})
test("an authorized workspace without models stays connected and can refresh", async ({ page }) => {
const { state, dialog } = await fixture(page)
state.models = false
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
state.status = "complete"
await expect(dialog.getByText(/this Console workspace has no available models/)).toBeVisible()
state.models = true
await dialog.getByRole("button", { name: "Refresh models" }).click()
await expect(dialog.getByRole("heading", { name: "Connected to OpenCode" })).toBeVisible()
expect(state.starts).toBe(1)
})
test("remote disclosure precedes authorization and all auth requests target that server", async ({ page }) => {
const { state, dialog } = await fixture(page, true)
await expect(dialog.getByRole("note")).toContainText("Connecting on “Production server”")
await expect(dialog.getByRole("note")).toContainText("credentials will be stored on this server")
expect(state.starts).toBe(0)
const request = page.waitForRequest(
(request) => request.method() === "POST" && request.url().includes("/connect/oauth"),
)
await dialog.getByRole("button", { name: "Continue with OpenCode Console" }).click()
expect(new URL((await request).url()).origin).toBe("http://production.example:4096")
await expect(dialog.getByRole("group", { name: "Device code: TFXS-STXG" })).toBeVisible()
const cancelled = page.waitForRequest(
(request) => request.method() === "DELETE" && request.url().includes("/connect/oauth"),
)
await dialog.getByRole("button", { name: "Close", exact: true }).click()
expect(new URL((await cancelled).url()).origin).toBe("http://production.example:4096")
})
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body class="overflow-hidden bg-v2-background-bg-deep">
<div id="root" class="flex h-dvh flex-col p-px"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
import { render } from "solid-js/web"
import { MemoryRouter } from "@solidjs/router"
import { AppBaseProviders, AppInterface } from "@/app"
import { PlatformProvider } from "@/runtime/platform/platform"
import { createBrowserDraftStore } from "@/runtime/persistence/drafts"
import { ServerConnection } from "@/runtime/server/registry"
// Exercise the real Desktop renderer with local browser/clipboard adapters and
// an HTTP fixture. No Electron service or account credentials are touched.
const params = new URLSearchParams(window.location.search)
const remote = params.get("server")
const server: ServerConnection.Any = remote
? { type: "http", displayName: "Production server", http: { url: remote } }
: { type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } }
const root = document.getElementById("root")
if (!root) throw new Error("Missing fixture root")
render(
() => (
<PlatformProvider
value={{
platform: "desktop",
windowID: "console-auth-fixture",
os: "linux",
draftStore: createBrowserDraftStore(),
openExternal: () => {},
restart: async () => {},
notify: async () => {},
openDirectoryPickerDialog: async () => null,
writeClipboardText: (text) => navigator.clipboard.writeText(text),
openBrowser: async (url) => {
if (params.has("browserFailed")) return false
const browser = window.open("about:blank", "_blank")
if (!browser) return false
browser.opener = null
browser.location.replace(url)
return true
},
}}
>
<AppBaseProviders locale="en">
<AppInterface servers={[server]} defaultServer={ServerConnection.key(server)} router={MemoryRouter} />
</AppBaseProviders>
</PlatformProvider>
),
root,
)
@@ -0,0 +1,19 @@
import { defineConfig, devices } from "@playwright/test"
const port = Number(process.env.PLAYWRIGHT_PORT ?? 4454)
export default defineConfig({
testDir: ".",
outputDir: "../test-results/desktop",
timeout: 60000,
expect: { timeout: 10000 },
workers: 1,
retries: 0,
use: { baseURL: `http://127.0.0.1:${port}`, screenshot: "only-on-failure", serviceWorkers: "block" },
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: `bun run dev -- --host 127.0.0.1 --port ${port} --strictPort`,
url: `http://127.0.0.1:${port}`,
reuseExistingServer: true,
timeout: 120000,
},
})
+4 -1
View File
@@ -6,6 +6,7 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { MockApi, MockBadRequest, MockNotFound } from "./mock-api"
export interface MockServerConfig {
server?: string
provider: unknown | (() => unknown)
integrationMethods?: Record<string, unknown[]>
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
@@ -47,7 +48,9 @@ type MockStreamWindow = Window & {
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const server =
config.server ??
`http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
({ server, retry }) => {
+1
View File
@@ -19,6 +19,7 @@ const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0
export default defineConfig({
testDir: "./e2e",
testIgnore: [
"desktop/**",
"service-worker/**",
process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
],
+23 -3
View File
@@ -18,11 +18,14 @@ import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { useLanguage } from "@/runtime/i18n/language"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
import { useIntegrations } from "@/providers/catalog/integrations"
import { NEW_SESSION_CONTENT_WIDTH } from "@/new-session/layout"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import type { NewSessionWorkspaceController } from "./workspace/controller"
import { NewSessionWordmark } from "./wordmark"
import { ProviderSetup } from "@/providers/connect/setup"
import { usePlatform } from "@/runtime/platform/platform"
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
@@ -41,6 +44,10 @@ export function NewSessionView(props: {
project: PromptProjectController
workspace: NewSessionWorkspaceController
}) {
const platform = usePlatform()
const sdk = useWorkspaceLocation()
const providers = useProviders(() => sdk().directory)
const setup = () => platform.platform === "desktop" && providers.ready() && !providers.usable()
const [onboarding, setOnboarding, , onboardingReady] = persisted(
Persist.global("workspace-onboarding"),
WorkspaceOnboardingSchema,
@@ -61,11 +68,20 @@ export function NewSessionView(props: {
active={props.composer.state.drag === "active"}
input={props.composer.model.selection.current()?.capabilities.input}
/>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div
class="absolute inset-x-0 top-[25.375%] flex justify-center px-6"
classList={{ "bottom-12 overflow-y-auto": setup() }}
>
<div class={NEW_SESSION_CONTENT_WIDTH}>
<NewSessionWordmark />
<div class="mt-8 flex flex-col gap-8">
<Composer model={props.composer} />
<ProviderSetup
visible={setup()}
directory={sdk().directory}
selection={props.composer.model.selection}
onDone={props.composer.restoreFocus}
/>
<Show when={props.project.empty()}>
<PromptProjectAddButton controller={props.project} />
</Show>
@@ -133,8 +149,9 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
const dialog = useDialog()
const sdk = useWorkspaceLocation()
const providers = useProviders(() => sdk().directory)
const integrations = useIntegrations(() => sdk().directory)
const [providerState, setProviderState, , providerReady] = persisted(
Persist.global("new-session.provider-tip"),
Persist.global("new-session.provider-tip-v3"),
ProviderTipSchema,
{ dismissedAt: 0 },
)
@@ -153,7 +170,10 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
() =>
providers.ready() &&
providerReady() &&
providers.paid().length === 0 &&
!integrations.list().some((integration) => integration.connections.length > 0) &&
!providers
.connected()
.some((provider) => provider.id !== "opencode" && Object.keys(provider.models).length > 0) &&
Date.now() - providerState.dismissedAt >= providerTipDismissalDuration,
)
const tip = createMemo<"workspace" | "provider" | undefined>(() => {
@@ -45,6 +45,10 @@ export function useProviders(directory: Accessor<string | undefined>) {
},
all: () => providers().all,
default: () => providers().default,
usable: () =>
(data.location.model.list(location()) ?? []).some(
(model) => model.enabled && model.status !== "deprecated" && providers().connected.includes(model.providerID),
),
// V2 servers list only available providers, so the connectable catalog
// comes from the integration list, with the provider catalog as fallback.
popular: () => {
@@ -72,15 +76,12 @@ export function useProviders(directory: Accessor<string | undefined>) {
},
paid: () => {
const connected = new Set(providers().connected)
const paid = [
...Iterable.filter(
providers().all,
([id]) =>
connected.has(id) &&
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
),
]
return paid
const paid = new Set(
(data.location.model.list(location()) ?? [])
.filter((model) => model.enabled && model.cost.some((cost) => cost.input > 0))
.map((model) => model.providerID),
)
return [...Iterable.filter(providers().all, ([id]) => connected.has(id) && paid.has(id))]
},
}
}
@@ -0,0 +1,68 @@
import { For, Show } from "solid-js"
import { Button } from "@opencode/ui/button"
import { TextShimmer } from "@opencode/ui/text-shimmer"
import { useLanguage } from "@/runtime/i18n/language"
export function ConsoleAuthorization(props: {
code: string
browserFailed: boolean
copied: boolean
copyFailed: boolean
onOpen: () => void
onCopy: () => void
}) {
const language = useLanguage()
return (
<div
data-component="console-authorization"
class="flex flex-col gap-5 text-[13px] leading-5 text-v2-text-text-muted"
>
<p>
{language.t(
props.browserFailed ? "provider.connect.console.browserFailed" : "provider.connect.console.instructions",
)}
</p>
<div class="flex flex-col gap-2">
<div class="font-medium text-v2-text-text-base">{language.t("provider.connect.console.deviceCode")}</div>
<div
dir="ltr"
role="group"
aria-label={`${language.t("provider.connect.console.deviceCode")}: ${props.code}`}
class="flex max-w-full gap-1 self-start font-mono text-xl font-[530] text-v2-text-text-base tabular-nums"
>
<For each={props.code.split("")}>
{(character) => (
<span
aria-hidden="true"
class={
character === "-"
? "mx-1 flex h-12 items-center text-v2-text-text-muted"
: "flex h-12 w-8 items-center justify-center rounded-md border border-v2-border-border-base bg-v2-background-bg-layer-02"
}
>
{character}
</span>
)}
</For>
</div>
<p role="status">
<TextShimmer text={language.t("provider.connect.console.waiting")} active />
</p>
</div>
<div data-component="console-browser-fallback" class="flex min-h-7 flex-wrap items-center gap-x-3 gap-y-1">
<span class="text-v2-text-text-faint">{language.t("provider.connect.console.browserHint")}</span>
<Button variant="ghost-muted" onClick={props.onCopy}>
{language.t(props.copied ? "provider.connect.console.linkCopied" : "provider.connect.console.copyLink")}
</Button>
<Show when={props.browserFailed || props.copyFailed}>
<Button variant="ghost" onClick={props.onOpen}>
{language.t("provider.connect.console.openAgain")}
</Button>
</Show>
</div>
<Show when={props.copyFailed}>
<p role="alert">{language.t("provider.connect.console.copyFailed")}</p>
</Show>
</div>
)
}
+144 -30
View File
@@ -13,18 +13,20 @@ export function createProviderConnectionController(options: {
provider: () => string
directory: () => string | undefined
onComplete: () => void
initialMethod?: string
pollInterval?: number
}) {
const language = useLanguage()
const platform = usePlatform()
const serverSDK = useServerSDK()
const data = useData()
const location = () => {
const directory = options.directory()
return directory ? { directory } : undefined
}
// An authorization belongs to the server and Location where it began.
const directory = options.directory()
const integrationID = options.provider()
const desktopConsole = platform.platform === "desktop" && integrationID === "opencode"
const location = () => (directory ? { directory } : undefined)
const [integration] = createResource(
() => ({ provider: options.provider(), directory: options.directory() }),
() => ({ provider: integrationID, directory }),
(input) =>
serverSDK.api.integration
.get({ integrationID: input.provider, location: location() })
@@ -41,8 +43,12 @@ export function createProviderConnectionController(options: {
methodIndex: undefined as number | undefined,
authorization: undefined as Authorization | undefined,
formAnswer: undefined as FormAnswer | undefined,
state: "pending" as "pending" | "complete" | "error" | "form" | undefined,
state: "pending" as "pending" | "waiting" | "refreshing" | "ready" | "error" | "form" | undefined,
error: undefined as string | undefined,
connected: false,
browserFailed: false,
statusFailed: false,
selectingIndex: undefined as number | undefined,
})
const polling = {
generation: 0,
@@ -59,7 +65,7 @@ export function createProviderConnectionController(options: {
| { type: "auth.form" }
| { type: "auth.answer"; answer: FormAnswer | undefined }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: Authorization }
| { type: "auth.authorized"; index: number; authorization: Authorization }
| { type: "auth.error"; error: string }
const dispatch = (action: Action) => {
@@ -71,6 +77,10 @@ export function createProviderConnectionController(options: {
draft.formAnswer = undefined
draft.state = undefined
draft.error = undefined
draft.connected = false
draft.browserFailed = false
draft.statusFailed = false
draft.selectingIndex = undefined
return
}
if (action.type === "method.reset") {
@@ -79,6 +89,10 @@ export function createProviderConnectionController(options: {
draft.formAnswer = undefined
draft.state = undefined
draft.error = undefined
draft.connected = false
draft.browserFailed = false
draft.statusFailed = false
draft.selectingIndex = undefined
return
}
if (action.type === "auth.form") {
@@ -95,12 +109,15 @@ export function createProviderConnectionController(options: {
if (action.type === "auth.pending") {
draft.state = "pending"
draft.error = undefined
draft.selectingIndex = undefined
return
}
if (action.type === "auth.complete") {
draft.state = "complete"
if (action.type === "auth.authorized") {
draft.methodIndex = action.index
draft.state = "waiting"
draft.authorization = action.authorization
draft.error = undefined
draft.selectingIndex = undefined
return
}
draft.state = "error"
@@ -115,24 +132,59 @@ export function createProviderConnectionController(options: {
clearTimeout(polling.timer)
polling.timer = undefined
}
const cancelAttempt = (authorization = store.authorization) => {
if (!desktopConsole) return
if (!authorization || (authorization.attemptID === store.authorization?.attemptID && store.connected)) return
void serverSDK.api.integration.oauth
.cancel({
integrationID,
attemptID: authorization.attemptID,
location: location(),
})
.catch(() => undefined)
}
const openBrowser = async () => {
const authorization = store.authorization
if (!authorization) return
const generation = polling.generation
const opened = await Promise.resolve()
.then(async () => {
if (platform.openBrowser) return platform.openBrowser(authorization.url)
platform.openExternal(authorization.url)
return true
})
.then((result) => result !== false)
.catch(() => false)
if (polling.disposed || generation !== polling.generation) return
setStore("browserFailed", !opened)
}
const finish = async () => {
cancelPolling()
const generation = polling.generation
setStore({ connected: true, state: "refreshing", error: undefined })
const ref = location()
data.location.integration.invalidate(ref)
data.location.provider.invalidate(ref)
data.location.model.invalidate(ref)
await Promise.all([
const refreshed = await Promise.all([
data.location.integration.sync(ref),
data.location.provider.sync(ref),
data.location.model.sync(ref),
]).catch(() => undefined)
if (polling.disposed) return
])
.then(() => true)
.catch(() => false)
if (polling.disposed || generation !== polling.generation) return
if (!refreshed && desktopConsole) {
dispatch({ type: "auth.error", error: language.t("provider.connect.console.refreshFailed") })
return
}
setStore("state", "ready")
options.onComplete()
}
const poll = async (authorization: Authorization, generation: number) => {
const result = await serverSDK.api.integration.oauth
.status({
integrationID: options.provider(),
integrationID,
attemptID: authorization.attemptID,
location: location(),
})
@@ -140,9 +192,14 @@ export function createProviderConnectionController(options: {
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
setStore("statusFailed", true)
dispatch({
type: "auth.error",
error: result.error instanceof Error ? result.error.message : String(result.error),
error: desktopConsole
? language.t("provider.connect.console.statusFailed")
: result.error instanceof Error
? result.error.message
: String(result.error),
})
return
}
@@ -151,20 +208,37 @@ export function createProviderConnectionController(options: {
return
}
if (result.status.status === "failed") {
dispatch({ type: "auth.error", error: result.status.message })
const message = result.status.message
dispatch({
type: "auth.error",
error:
desktopConsole && message.includes("expired_token")
? language.t("provider.connect.console.expired")
: desktopConsole && message.includes("access_denied")
? language.t("provider.connect.console.denied")
: message,
})
return
}
if (result.status.status === "expired") {
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
dispatch({
type: "auth.error",
error: language.t(desktopConsole ? "provider.connect.console.expired" : "common.requestFailed"),
})
return
}
polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000)
polling.timer = setTimeout(
() => void poll(authorization, generation),
options.pollInterval ?? (desktopConsole ? 500 : 1_000),
)
}
const select = async (index: number, answer?: FormAnswer) => {
cancelPolling()
cancelAttempt()
const generation = polling.generation
const selected = methods()[index]
dispatch({ type: "method.select", index })
const awaitAuthorization = desktopConsole && selected.type === "oauth" && selected.id === "device"
if (!awaitAuthorization) dispatch({ type: "method.select", index })
if (selected.form?.length && !answer) {
dispatch({ type: "auth.form" })
return
@@ -175,41 +249,62 @@ export function createProviderConnectionController(options: {
}
if (selected.type !== "oauth") return
if (selected.form?.some((field) => field.type !== "string")) {
dispatch({ type: "auth.error", error: "This authentication form contains unsupported fields" })
dispatch({ type: "auth.error", error: language.t("provider.connect.form.unsupported") })
return
}
dispatch({ type: "auth.pending" })
if (awaitAuthorization) {
setStore({
selectingIndex: index,
authorization: undefined,
state: undefined,
error: undefined,
browserFailed: false,
statusFailed: false,
})
} else {
dispatch({ type: "auth.pending" })
}
const result = await serverSDK.api.integration.oauth
.connect({
integrationID: options.provider(),
integrationID,
methodID: selected.id,
...(answer ? { answer } : {}),
location: location(),
})
.then((response) => {
if (options.provider() === "opencode" && platform.platform === "desktop") {
if (integrationID === "opencode" && platform.platform === "desktop") {
const url = new URL(response.data.url)
url.searchParams.set("client_id", "opencode-desktop")
url.searchParams.set("return_window", platform.windowID)
response.data.url = url.href
}
return { ok: true as const, authorization: response.data }
})
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
dispatch({ type: "auth.error", error: String(result.error) })
if (polling.disposed || generation !== polling.generation) {
if (result.ok) cancelAttempt(result.authorization)
return
}
dispatch({ type: "auth.complete", authorization: result.authorization })
if (!result.ok) {
if (awaitAuthorization) dispatch({ type: "method.select", index })
dispatch({
type: "auth.error",
error: desktopConsole ? language.t("provider.connect.console.startFailed") : String(result.error),
})
return
}
dispatch({ type: "auth.authorized", index, authorization: result.authorization })
if (desktopConsole && selected.id === "device") void openBrowser()
if (result.authorization.mode === "auto") void poll(result.authorization, generation)
}
const reset = () => {
cancelPolling()
cancelAttempt()
dispatch({ type: "method.reset" })
}
const connectKey = async (key: string) => {
await serverSDK.api.integration.connect.key({
integrationID: options.provider(),
integrationID,
location: location(),
key,
...(store.formAnswer ? { answer: store.formAnswer } : {}),
@@ -221,7 +316,7 @@ export function createProviderConnectionController(options: {
if (!authorization) return language.t("provider.connect.oauth.code.invalid")
const result = await serverSDK.api.integration.oauth
.complete({
integrationID: options.provider(),
integrationID,
attemptID: authorization.attemptID,
location: location(),
code,
@@ -238,13 +333,20 @@ export function createProviderConnectionController(options: {
let auto = false
createEffect(() => {
if (auto || integration.loading || methods().length !== 1) return
if (auto || integration.loading) return
const index = options.initialMethod
? methods().findIndex((method) => method.type === "oauth" && method.id === options.initialMethod)
: methods().length === 1
? 0
: -1
if (index < 0) return
auto = true
void select(0)
void select(index)
})
onCleanup(() => {
polling.disposed = true
cancelPolling()
cancelAttempt()
})
return {
@@ -254,6 +356,9 @@ export function createProviderConnectionController(options: {
currentMethod,
methodIndex: () => store.methodIndex,
authorization: () => store.authorization,
browserFailed: () => store.browserFailed,
selecting: (index: number) => store.selectingIndex === index,
openBrowser,
auth: {
state: () => store.state,
error: () => store.error,
@@ -261,6 +366,15 @@ export function createProviderConnectionController(options: {
reset,
connectKey,
completeCode,
refresh: finish,
retry: () => {
if (store.connected) return finish()
if (store.statusFailed && store.authorization) {
setStore({ state: "waiting", error: undefined, statusFailed: false })
return poll(store.authorization, polling.generation)
}
return store.methodIndex === undefined ? Promise.resolve() : select(store.methodIndex, store.formAnswer)
},
},
}
}
+645 -144
View File
@@ -4,11 +4,23 @@ import { Icon } from "@opencode/ui/icon"
import { List } from "@opencode/ui/list"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { Spinner } from "@opencode/ui/spinner"
import { Loader } from "@opencode/ui/loader"
import { TextField } from "@opencode/ui/text-field"
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog"
import { TextInput } from "@opencode/ui/text-input"
import { showToast } from "@/shell/notifications/toast"
import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
import {
type Component,
createEffect,
createMemo,
createResource,
createUniqueId,
For,
Match,
onMount,
Show,
Switch,
} from "solid-js"
import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/runtime/platform/external-link"
@@ -18,6 +30,18 @@ import { useIntegrations } from "@/providers/catalog/integrations"
import { CustomProviderForm } from "@/providers/credentials/dialog"
import { decode64 } from "@/runtime/persistence/base64"
import { createProviderConnectionController, type ProviderConnectMethod } from "./controller"
import { ConsoleAuthorization } from "./console"
import { OpenCodeLogo } from "@/providers/opencode-logo"
import { usePlatform } from "@/runtime/platform/platform"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { authServerName, RemoteAuthNotice } from "./remote"
import type { ModelSelection } from "@/providers/models/selection"
import { ServerConnection } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { useSettingsSurface } from "@/settings/surface"
import { SettingsList } from "@/settings/list"
import "./models.css"
const CUSTOM_ID = "_custom"
type IntegrationForm = NonNullable<ProviderConnectMethod["form"]>[number]
@@ -37,9 +61,21 @@ export function useProviderConnectController(options: { onBack?: () => void } =
export const DialogConnectProvider: Component<{
directory?: string
controller?: ReturnType<typeof useProviderConnectController>
provider?: string
initialMethod?: string
selection?: ModelSelection
onDone?: () => void
onConnected?: (provider: string) => void
}> = (props) => {
const fallback = useProviderConnectController()
if (props.provider) fallback.select(props.provider)
const controller = props.controller ?? fallback
const platform = usePlatform()
const [state, setState] = createStore({
completed: false,
modelProvider: undefined as { id: string; name: string } | undefined,
authorization: false,
})
const language = useLanguage()
const reset = controller.back
const back = { current: reset }
@@ -56,13 +92,22 @@ export const DialogConnectProvider: Component<{
<Match when={controller.selected() === CUSTOM_ID}>
<CustomProviderForm autofocus={false} />
</Match>
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
<Match
keyed
when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}
>
{(provider) => (
<ProviderConnection
provider={provider()}
provider={provider}
directory={props.directory}
onBack={reset}
setBack={(handler) => (back.current = handler)}
initialMethod={props.initialMethod}
selection={props.selection}
onDone={props.onDone ? () => setState("completed", true) : undefined}
onConnected={() => props.onConnected?.(provider)}
onFirstConnection={(provider) => setState("modelProvider", provider)}
onAuthorization={(authorization) => setState("authorization", authorization)}
/>
)}
</Match>
@@ -75,25 +120,58 @@ export const DialogConnectProvider: Component<{
return (
<Dialog
containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
containerClass={
state.modelProvider
? "!h-[min(calc(100vh_-_16px),560px)] !w-[min(calc(100vw_-_16px),640px)]"
: platform.platform === "desktop" && controller.selected() === "opencode" && state.authorization
? "!h-auto !max-h-[min(calc(100vh_-_16px),560px)] !w-[min(calc(100vw_-_16px),640px)]"
: "!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
}
onCloseAutoFocus={(event) => {
if (!state.completed || !props.onDone) return
event.preventDefault()
props.onDone()
}}
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":
platform.platform === "desktop" && controller.selected() === "opencode" && !state.modelProvider,
"[&_[data-slot=dialog-header]]:!pt-5": !!state.modelProvider,
}}
>
<DialogHeader closeLabel={language.t("common.close")}>
<Show
when={controller.selected()}
fallback={<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>}
>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => back.current()}
aria-label={language.t("common.goBack")}
>
<Icon name="arrow-left" size="small" />
</button>
</Show>
<Switch>
<Match when={state.modelProvider}>
{(provider) => (
<div class="flex items-center gap-2">
<Show
when={provider().id === "opencode"}
fallback={<ProviderIcon id={provider().id} class="size-4 shrink-0" />}
>
<OpenCodeLogo class="size-4 shrink-0" />
</Show>
<DialogTitle>{language.t("provider.connect.models.title", { provider: provider().name })}</DialogTitle>
</div>
)}
</Match>
<Match when={controller.selected()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => back.current()}
aria-label={language.t("common.goBack")}
>
<Icon name="arrow-left" size="small" />
</button>
</Match>
<Match when={true}>
<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>
</Match>
</Switch>
</DialogHeader>
<DialogBody class="min-h-0 flex-1 overflow-hidden px-2 pb-2">
<DialogBody
class={`min-h-0 flex-1 overflow-hidden px-2 ${state.modelProvider || (platform.platform === "desktop" && controller.selected() === "opencode") ? "pb-0" : "pb-2"}`}
>
<div ref={focusHost} tabIndex={-1} class="flex min-h-0 flex-1 flex-col outline-none">
<Content />
</div>
@@ -115,7 +193,9 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
const all = createMemo(() => {
language.locale()
const query = store.filter.trim().toLowerCase()
const values = [custom(), ...integrations.list()]
const values = [custom(), ...integrations.list()].map((provider) =>
provider.id === "opencode" ? { ...provider, name: language.t("provider.connect.console.name") } : provider,
)
if (!query) return values
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
})
@@ -205,7 +285,12 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
aria-busy={store.connecting === provider.id}
onClick={() => connect(provider.id)}
>
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-base" />
<Show
when={provider.id === "opencode"}
fallback={<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-base" />}
>
<OpenCodeLogo class="size-4 shrink-0" />
</Show>
<span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
<span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
@@ -254,18 +339,62 @@ function ProviderConnection(props: {
directory?: string
onBack: () => void
setBack: (handler: () => void) => void
initialMethod?: string
selection?: ModelSelection
onDone?: () => void
onConnected?: () => void
onFirstConnection: (provider: { id: string; name: string }) => void
onAuthorization: (authorization: boolean) => void
}) {
const dialog = useDialog()
const params = useParams()
const language = useLanguage()
const providers = useProviders(() => props.directory)
const directory = () => props.directory ?? decode64(params.dir)
const initialDirectory = props.directory ?? decode64(params.dir)
const directory = () => initialDirectory
const platform = usePlatform()
const sdk = useServerSDK()
const data = useData()
const tabs = useTabs()
const surface = useSettingsSurface()
const integrations = useIntegrations(directory)
const desktopConsole = platform.platform === "desktop" && props.provider === "opencode"
const remote = desktopConsole && authServerName(sdk.server) !== undefined
const initialModel = props.selection?.current()
const [consoleState, setConsoleState] = createStore({
copied: false,
copyFailed: false,
firstConnection: undefined as boolean | undefined,
models: false,
selectedModel: "",
collapsed: {} as Record<string, boolean>,
})
const consoleMethod = () => {
const method = controller.currentMethod()
return desktopConsole && method?.type === "oauth" && method.id === "device"
}
const done = () => {
props.onDone?.()
dialog.close()
}
const controller = createProviderConnectionController({
provider: () => props.provider,
directory,
initialMethod: remote ? undefined : props.initialMethod,
onComplete: () => {
props.onConnected?.()
if (consoleState.firstConnection) {
if (connectionModels().length > 0) {
const first = connectionGroups()[0]?.models[0]
setConsoleState({ models: true, selectedModel: first ? modelKey(first) : "" })
props.onFirstConnection({ id: props.provider, name: connectedProviderName() })
return
}
if (consoleMethod()) return
}
dialog.close()
surface.open("providers")
showToast({
variant: "success",
icon: "circle-check",
@@ -274,18 +403,98 @@ function ProviderConnection(props: {
})
},
})
createEffect(() => {
const current = controller.integration()
if (!current || consoleState.firstConnection !== undefined) return
const existingConnection = integrations.list().some((integration) => integration.connections.length > 0)
const existingProvider = providers
.connected()
.some((provider) => provider.id !== "opencode" && Object.keys(provider.models).length > 0)
setConsoleState("firstConnection", !existingConnection && !existingProvider)
})
const [defaultModel] = createResource(
() => controller.auth.state() === "ready" && consoleMethod(),
() =>
sdk.api.model
.default({ location: initialDirectory ? { directory: initialDirectory } : undefined })
.then((response) => response.data)
.catch(() => undefined),
)
const consoleModels = createMemo(() => {
const location = initialDirectory ? { directory: initialDirectory } : undefined
const connected = new Set(
(data.location.provider.list(location) ?? [])
.filter((provider) => provider.integrationID === "opencode")
.map((provider) => provider.id),
)
return (data.location.model.list(location) ?? [])
.filter((model) => connected.has(model.providerID) && model.enabled && model.status !== "deprecated")
.toSorted((a, b) => a.providerID.localeCompare(b.providerID) || a.id.localeCompare(b.id))
})
const connectionModels = createMemo(() => {
const location = initialDirectory ? { directory: initialDirectory } : undefined
const ids = new Set(
(data.location.provider.list(location) ?? [])
.filter((provider) => provider.id === props.provider || provider.integrationID === props.provider)
.map((provider) => provider.id),
)
return (data.location.model.list(location) ?? []).filter(
(model) => ids.has(model.providerID) && model.enabled && model.status !== "deprecated",
)
})
const connectionGroups = createMemo(() => {
const location = initialDirectory ? { directory: initialDirectory } : undefined
const models = connectionModels()
return (data.location.provider.list(location) ?? [])
.filter((provider) => provider.id === props.provider || provider.integrationID === props.provider)
.map((provider) => ({ provider, models: models.filter((model) => model.providerID === provider.id) }))
.filter((group) => group.models.length > 0)
})
const modelKey = (model: { providerID: string; id: string }) => `${model.providerID}:${model.id}`
const selectedModel = () => connectionModels().find((model) => modelKey(model) === consoleState.selectedModel)
const connectedProviderName = () => (props.provider === "opencode" ? "OpenCode" : provider().name)
const recommended = () => {
const current = props.selection?.current()
if (initialModel && current?.id === initialModel.id && current.provider.id === initialModel.provider.id)
return current
const preferred = defaultModel()
return (
consoleModels().find((model) => model.providerID === preferred?.providerID && model.id === preferred.id) ??
consoleModels()[0]
)
}
const copyLink = async () => {
const url = controller.authorization()?.url
if (!url) return
const copied = await Promise.resolve()
.then(() => (platform.writeClipboardText ? platform.writeClipboardText(url) : navigator.clipboard.writeText(url)))
.then(() => true)
.catch(() => false)
if (controller.authorization()?.url !== url) return
setConsoleState({ copied, copyFailed: !copied })
}
createEffect(() => {
controller.authorization()?.attemptID
setConsoleState({ copied: false, copyFailed: false })
})
createEffect(() => props.onAuthorization(controller.authorization() !== undefined))
const provider = createMemo(() => ({
id: props.provider,
name: providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
name:
props.provider === "opencode"
? language.t("provider.connect.console.name")
: (providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider),
}))
const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return ""
if (value.type === "key") return language.t("provider.connect.method.apiKey")
if (value.type === "key")
return language.t(desktopConsole ? "provider.connect.console.serviceKey" : "provider.connect.method.apiKey")
return value.label ?? ""
}
const methodDetails = (value?: { type?: string; label?: string }) => {
const label = methodLabel(value)
if (desktopConsole && value?.type === "key") return { label }
const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i)
const hint = suffix?.[1]
return {
@@ -432,33 +641,76 @@ function ProviderConnection(props: {
props.setBack(goBack)
function MethodSelection() {
const primary = () =>
desktopConsole
? controller.methods().findIndex((method) => method.type === "oauth" && method.id === "device")
: -1
const serviceAccount = () => controller.methods().findIndex((method) => method.type === "key")
return (
<div class="flex flex-col gap-2">
<div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
{language.t("provider.connect.selectMethod", { provider: provider().name })}
</div>
<div class="flex flex-col">
<For each={controller.methods()}>
{(item, index) => {
const details = () => methodDetails(item)
return (
<button
type="button"
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => void controller.auth.select(index())}
>
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
</span>
<span class="font-[530] text-v2-text-text-base">{details().label}</span>
<Show when={details().hint}>
{(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
<Show when={primary() >= 0}>
<div class="flex flex-col items-start gap-5">
<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={controller.selecting(primary())}
aria-busy={controller.selecting(primary())}
onClick={() => void controller.auth.select(primary())}
>
<Show when={controller.selecting(primary())}>
<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={controller.selecting(primary()) ? "true" : undefined}
classList={{ "opacity-0": controller.selecting(primary()) }}
>
{language.t("provider.connect.console.continue")}
</span>
</Button>
<Show when={serviceAccount() >= 0}>
<div data-component="console-service-account" class="flex h-7 items-center gap-1">
<span class="text-v2-text-text-faint">{language.t("provider.connect.console.serviceAccount")}</span>
<Button variant="ghost-muted" onClick={() => void controller.auth.select(serviceAccount())}>
{language.t("provider.connect.console.useApiKey")}
</Button>
</div>
</Show>
</div>
</Show>
<Show when={primary() < 0}>
<div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
{language.t("provider.connect.selectMethod", { provider: provider().name })}
</div>
<div class="flex flex-col">
<For each={controller.methods()}>
{(item, index) => {
const details = () => methodDetails(item)
return (
<Show when={index() !== primary()}>
<button
type="button"
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => void controller.auth.select(index())}
>
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
</span>
<span class="font-[530] text-v2-text-text-base">{details().label}</span>
<Show when={details().hint}>
{(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
</Show>
</button>
</Show>
</button>
)
}}
</For>
</div>
)
}}
</For>
</div>
</Show>
</div>
)
}
@@ -492,29 +744,38 @@ 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={provider().id === "opencode"}
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
>
<div class="flex flex-col gap-5">
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
<div>
{language.t("provider.connect.opencodeZen.visit.prefix")}
<ExternalLink
href="https://opencode.ai/zen"
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.opencodeZen.visit.link")}
</ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")}
<div
class={`flex flex-col gap-5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted ${desktopConsole ? "pb-1" : "px-3"}`}
>
<Show when={desktopConsole}>
<p>{language.t("provider.connect.console.serviceKeyDescription")}</p>
</Show>
<Show when={!desktopConsole}>
<Show
when={provider().id === "opencode"}
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
>
<div class="flex flex-col gap-5">
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
<div>
{language.t("provider.connect.opencodeZen.visit.prefix")}
<ExternalLink
href="https://opencode.ai/zen"
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.opencodeZen.visit.link")}
</ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
</div>
</Show>
</Show>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
<label class="flex w-full flex-col gap-2 font-[530] leading-4 text-v2-text-text-base">
<span data-component="provider-api-key-label">
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
</span>
<TextInput
ref={apiKey}
class="!w-full"
@@ -536,7 +797,12 @@ function ProviderConnection(props: {
</div>
)}
</Show>
<Button type="submit" variant="contrast" data-action="provider-connect-submit">
<Button
type="submit"
class={desktopConsole ? "!px-3" : undefined}
variant="contrast"
data-action="provider-connect-submit"
>
{language.t("common.continue")}
</Button>
</form>
@@ -582,7 +848,7 @@ function ProviderConnection(props: {
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
<label class="flex w-full flex-col gap-2 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.oauth.code.label", { method: controller.currentMethod()?.label ?? "" })}
<TextInput
ref={codeInput}
@@ -622,93 +888,328 @@ function ProviderConnection(props: {
})
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<ExternalLink href={controller.authorization()!.url}>
{language.t("provider.connect.oauth.auto.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div>
<TextField
label={language.t("provider.connect.oauth.auto.confirmationCode")}
class="font-mono"
value={code()}
readOnly
copyable
<Show
when={consoleMethod()}
fallback={
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<ExternalLink href={controller.authorization()!.url}>
{language.t("provider.connect.oauth.auto.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div>
<TextField
label={language.t("provider.connect.oauth.auto.confirmationCode")}
class="font-mono"
value={code()}
readOnly
copyable
/>
<div class="text-14-regular text-text-base flex items-center gap-4">
<Spinner />
<span>{language.t("provider.connect.status.waiting")}</span>
</div>
</div>
}
>
<ConsoleAuthorization
code={new URL(controller.authorization()!.url).searchParams.get("user_code") ?? code() ?? ""}
browserFailed={controller.browserFailed()}
copied={consoleState.copied}
copyFailed={consoleState.copyFailed}
onCopy={() => void copyLink()}
onOpen={() => void controller.openBrowser()}
/>
<div class="text-14-regular text-text-base flex items-center gap-4">
<Spinner />
<span>{language.t("provider.connect.status.waiting")}</span>
</Show>
)
}
const startWithModel = async () => {
const model = selectedModel()
if (!model) return
const selection = { providerID: model.providerID, modelID: model.id }
if (props.selection) {
props.selection.set(selection)
done()
return
}
dialog.close()
await tabs.newDraft(
{
server: ServerConnection.key(sdk.server),
directory: initialDirectory ?? data.location.default().directory,
},
undefined,
selection,
)
}
function FirstConnectionModels() {
return (
<div data-component="first-provider-models" class="flex min-h-0 flex-1 flex-col px-3">
<p class="shrink-0 pb-5 text-[13px] leading-5 text-v2-text-text-muted">
{language.t("provider.connect.models.description")}
</p>
<div
data-component="first-provider-model-scroll"
class="settings-panel settings-models min-h-0 flex-1 overflow-y-auto pb-4"
>
<div
role="radiogroup"
aria-label={language.t("provider.connect.models.list", { provider: connectedProviderName() })}
>
<For each={connectionGroups()}>
{(group) => {
const collapsible = () => connectionGroups().length > 1
const expanded = () => !collapsible() || !consoleState.collapsed[group.provider.id]
const label = () => (
<span class="settings-models-group-label">
<Show
when={group.provider.id === "opencode"}
fallback={<ProviderIcon id={group.provider.id} class="size-4 shrink-0" />}
>
<OpenCodeLogo class="size-4 shrink-0" />
</Show>
<span class="settings-section-title">{group.provider.name}</span>
</span>
)
return (
<section class="settings-section" data-expanded={expanded() ? "" : undefined}>
<h3
class="settings-models-group-header sticky top-0 z-[1] box-content bg-v2-background-bg-layer-01"
classList={{ "pb-2": collapsible() && !expanded() }}
>
<Show when={collapsible()} fallback={<div class="settings-models-group-trigger">{label()}</div>}>
<button
type="button"
class="settings-models-group-trigger"
aria-expanded={expanded()}
onClick={() => setConsoleState("collapsed", group.provider.id, expanded())}
>
<span class="settings-models-group-chevron">
<Icon
name="chevron-down"
size="small"
classList={{ "-rotate-90 rtl:rotate-90": !expanded() }}
/>
</span>
{label()}
</button>
</Show>
</h3>
<Show when={expanded()}>
<SettingsList>
<For each={group.models}>
{(model) => {
const selected = () => consoleState.selectedModel === modelKey(model)
return (
<button
type="button"
role="radio"
data-component="settings-row"
data-first-provider-model=""
data-selected={selected() ? "" : undefined}
aria-checked={selected()}
class="-mx-4 w-[calc(100%+32px)] px-4 text-start focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => setConsoleState("selectedModel", modelKey(model))}
>
<div data-slot="settings-row-copy">
<div data-slot="settings-row-title">
<span class="min-w-0 truncate">{model.name}</span>
</div>
</div>
<div data-slot="settings-row-control" class="size-4">
<Show when={selected()}>
<Icon name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
</Show>
</div>
</button>
)
}}
</For>
</SettingsList>
</Show>
</section>
)
}}
</For>
</div>
</div>
<div
data-component="first-provider-model-footer"
class="-mx-5 flex h-15 shrink-0 items-center justify-between border-t border-v2-border-border-muted px-4"
>
<Button
variant="ghost-muted"
icon="outline-sliders"
onClick={() => {
dialog.close()
surface.open("models")
}}
>
{language.t("dialog.model.manage")}
</Button>
<Button variant="contrast" disabled={!selectedModel()} onClick={() => void startWithModel()}>
{language.t("common.continue")}
</Button>
</div>
</div>
)
}
return (
<div class="flex min-h-0 flex-1 flex-col">
<div class="flex h-10 shrink-0 items-start gap-2 px-3">
<ProviderIcon id={props.provider} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
<div class="text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base">
<Switch>
<Match
when={props.provider === "anthropic" && controller.currentMethod()?.label?.toLowerCase().includes("max")}
>
{language.t("provider.connect.title.anthropicProMax")}
</Match>
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
</Switch>
</div>
</div>
<Show when={!consoleState.models} fallback={<FirstConnectionModels />}>
<div class="flex min-h-0 flex-1 flex-col">
<div>
<Switch>
<Match when={controller.loading()}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={controller.methodIndex() === undefined}>
<MethodSelection />
</Match>
<Match when={controller.auth.state() === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={controller.auth.state() === "form"}>
<AuthFormView />
</Match>
<Match when={controller.auth.state() === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>{language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}</span>
</div>
</div>
</Match>
<Match when={controller.currentMethod()?.type === "key"}>
<ApiAuthView />
</Match>
<Match when={controller.currentMethod()?.type === "oauth"}>
<div
class={
desktopConsole ? "flex shrink-0 items-center gap-2 px-3 pb-6" : "flex h-10 shrink-0 items-start gap-2 px-3"
}
>
<Show
when={props.provider === "opencode"}
fallback={<ProviderIcon id={props.provider} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />}
>
<OpenCodeLogo class="size-4 shrink-0" />
</Show>
<div class="text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base">
<DialogTitle>
<Switch>
<Match when={controller.authorization()?.mode === "code"}>
<OAuthCodeView />
</Match>
<Match when={controller.authorization()?.mode === "auto"}>
<OAuthAutoView />
<Match when={consoleMethod()}>{language.t("provider.connect.console.title")}</Match>
<Match
when={
props.provider === "anthropic" && controller.currentMethod()?.label?.toLowerCase().includes("max")
}
>
{language.t("provider.connect.title.anthropicProMax")}
</Match>
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
</Switch>
</Match>
</Switch>
</DialogTitle>
</div>
</div>
<div
class={
desktopConsole ? "flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pb-4" : "flex min-h-0 flex-1 flex-col"
}
>
<Show when={remote}>
<div class="mb-5">
<RemoteAuthNotice server={sdk.server} />
</div>
</Show>
<div>
<Switch>
<Match when={controller.auth.state() === "ready" && consoleMethod()}>
<div class="flex flex-col items-start gap-5 text-[13px] leading-5 text-v2-text-text-muted">
<div role="status">
<p class="flex items-center gap-2 font-medium text-v2-text-text-base">
<Icon name="circle-check" />
{language.t("provider.connect.console.connected")}
</p>
<p>
{language.t(
consoleModels().length ? "provider.connect.console.ready" : "provider.connect.console.noModels",
)}
</p>
</div>
<Show when={recommended()}>
{(model) => (
<p>
{language.t("provider.connect.console.model")}{" "}
<span class="font-medium text-v2-text-text-base">{model().name}</span>
</p>
)}
</Show>
<Show
when={consoleModels().length > 0}
fallback={
<>
<Button onClick={() => platform.openExternal("https://opencode.ai/console")}>
{language.t("provider.connect.console.openAgain")}
</Button>
<Button onClick={() => void controller.auth.refresh()}>
{language.t("provider.connect.console.refresh")}
</Button>
</>
}
>
<Button
variant="contrast"
disabled={defaultModel.loading}
onClick={() => {
const model = recommended()
if (props.selection && model)
props.selection.set({ providerID: model.providerID, modelID: model.id })
done()
}}
>
{language.t(props.onDone ? "provider.connect.console.start" : "provider.connect.console.done")}
</Button>
</Show>
</div>
</Match>
<Match when={desktopConsole && controller.auth.state() === "refreshing"}>
<p role="status" class="text-[13px] leading-5 text-v2-text-text-muted">
{language.t("provider.connect.console.refreshing")}
</p>
</Match>
<Match when={controller.loading()}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={controller.methodIndex() === undefined}>
<MethodSelection />
</Match>
<Match when={controller.auth.state() === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={controller.auth.state() === "form"}>
<AuthFormView />
</Match>
<Match when={controller.auth.state() === "error"}>
<div class="text-14-regular text-text-base" role="alert">
<div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>
{desktopConsole
? controller.auth.error()
: language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}
</span>
</div>
<Show when={desktopConsole}>
<Button class="mt-4" onClick={() => void controller.auth.retry()}>
{language.t("provider.connect.console.retry")}
</Button>
</Show>
</div>
</Match>
<Match when={controller.currentMethod()?.type === "key"}>
<ApiAuthView />
</Match>
<Match when={controller.currentMethod()?.type === "oauth"}>
<Switch>
<Match when={controller.authorization()?.mode === "code"}>
<OAuthCodeView />
</Match>
<Match when={controller.authorization()?.mode === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
</Match>
</Switch>
</div>
</div>
</div>
</div>
</Show>
)
}
@@ -0,0 +1,17 @@
[data-first-provider-model]:has(+ [data-first-provider-model]:hover),
[data-first-provider-model]:hover {
border-block-end-color: transparent;
}
[data-first-provider-model]:hover {
background-color: var(--v2-overlay-simple-overlay-hover);
}
[data-component="first-provider-models"] [data-component="settings-list"] {
background-color: var(--v2-background-bg-layer-02);
overflow: hidden;
}
[data-component="first-provider-models"] .settings-section[data-expanded] {
padding-bottom: 0;
}
@@ -0,0 +1,30 @@
import { expect, test } from "bun:test"
import { authServerName } from "./remote"
test("SSH disclosure uses the remote identity even with a loopback proxy", () => {
expect(authServerName({ type: "ssh", host: "production.example", http: { url: "http://127.0.0.1:4096" } })).toBe(
"production.example",
)
expect(
authServerName({
type: "ssh",
host: "production.example",
displayName: "Production server",
http: { url: "http://127.0.0.1:4096" },
}),
).toBe("Production server")
})
test("local Desktop and loopback HTTP connections do not show remote disclosure", () => {
expect(authServerName({ type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } })).toBeUndefined()
for (const host of ["localhost", "127.0.0.1", "[::1]"]) {
expect(authServerName({ type: "http", http: { url: `http://${host}:4096` } })).toBeUndefined()
}
})
test("WSL and remote HTTP connections show their server identity", () => {
expect(
authServerName({ type: "sidecar", variant: "wsl", distro: "Ubuntu", http: { url: "http://127.0.0.1:4096" } }),
).toBe("Ubuntu")
expect(authServerName({ type: "http", http: { url: "https://production.example" } })).toBe("production.example")
})
@@ -0,0 +1,30 @@
import { Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection, serverName } from "@/runtime/server/registry"
export function authServerName(server: ServerConnection.Any) {
if (ServerConnection.builtin(server)) return undefined
if (server.type === "http" && ["localhost", "127.0.0.1", "[::1]"].includes(new URL(server.http.url).hostname))
return undefined
if (server.type === "sidecar" && server.variant === "wsl") return server.displayName ?? server.distro
return serverName(server)
}
export function RemoteAuthNotice(props: { server: ServerConnection.Any }) {
const language = useLanguage()
return (
<Show when={authServerName(props.server)}>
{(name) => (
<div
class="rounded-md border border-v2-border-border-base bg-v2-background-bg-layer-02 p-3 text-[13px] leading-5"
role="note"
>
<p class="font-medium text-v2-text-text-base">
{language.t("provider.connect.remote.title", { server: name() })}
</p>
<p class="text-v2-text-text-muted">{language.t("provider.connect.remote.description")}</p>
</div>
)}
</Show>
)
}
@@ -0,0 +1,51 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import type { ModelSelection } from "@/providers/models/selection"
import { RemoteAuthNotice } from "./remote"
import { Show } from "solid-js"
export function ProviderSetup(props: {
visible: boolean
directory: string
selection: ModelSelection
onDone: () => void
}) {
const language = useLanguage()
const dialog = useDialog()
const sdk = useServerSDK()
const open = async (console: boolean) => {
const { DialogConnectProvider } = await import("./dialog")
void dialog.show(() => (
<DialogConnectProvider
directory={props.directory}
provider={console ? "opencode" : undefined}
initialMethod={console ? "device" : undefined}
selection={props.selection}
onDone={props.onDone}
/>
))
}
return (
<Show when={props.visible}>
<section
data-component="provider-setup"
class="flex flex-col gap-4 rounded-lg border border-v2-border-border-base bg-v2-background-bg-layer-01 p-5 text-[13px] leading-5"
>
<div class="flex flex-col gap-1">
<h2 class="text-[15px] font-medium text-v2-text-text-base">{language.t("provider.setup.title")}</h2>
<p class="text-v2-text-text-muted">{language.t("provider.connect.console.intro")}</p>
</div>
<RemoteAuthNotice server={sdk.server} />
<div class="flex flex-wrap gap-2">
<Button variant="contrast" onClick={() => void open(true)}>
{language.t("provider.connect.console.continue")}
</Button>
<Button onClick={() => void open(false)}>{language.t("provider.setup.other")}</Button>
</div>
<p class="text-v2-text-text-muted">{language.t("provider.setup.settings")}</p>
</section>
</Show>
)
}
@@ -0,0 +1,17 @@
export function OpenCodeLogo(props: { class?: string }) {
return (
<svg
data-component="opencode-logo"
aria-hidden="true"
class={props.class}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g transform="translate(1.2 1.2) scale(0.85)">
<path opacity="0.2" d="M11.1999 12.8H4.79993V6.40002H11.1999V12.8Z" fill="currentColor" />
<path d="M11.2 3.2H4.79998V12.8H11.2V3.2ZM14.4 16H1.59998V0H14.4V16Z" fill="currentColor" />
</g>
</svg>
)
}
+49
View File
@@ -189,6 +189,53 @@ export const dict = {
"dialog.provider.viewAll": "Show more providers",
"provider.connect.title": "Connect {{provider}}",
"provider.connect.console.title": "Connect OpenCode Console account",
"provider.connect.console.name": "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",
"provider.connect.console.waiting": "Waiting for confirmation…",
"provider.connect.console.browserHint": "Browser didn't open?",
"provider.connect.console.copyLink": "Copy sign-in link",
"provider.connect.console.linkCopied": "Sign-in link copied",
"provider.connect.console.copyFailed": "Couldn't copy the sign-in link. Open Console again to continue.",
"provider.connect.console.openAgain": "Open Console again",
"provider.connect.console.browserFailed":
"We couldn't open your browser. Try again or copy the sign-in link to continue.",
"provider.connect.console.expired": "This sign-in request has expired. Start again to get a new device code.",
"provider.connect.console.denied": "Access was denied in Console. Try again when you're ready to connect.",
"provider.connect.console.statusFailed": "Couldn't check authorization. Check your server connection and try again.",
"provider.connect.console.startFailed": "Couldn't start sign-in. Check your server connection and try again.",
"provider.connect.models.title": "Connected to {{provider}}",
"provider.connect.models.description": "Choose a model to start with. You can switch models anytime.",
"provider.connect.models.list": "Models available from {{provider}}",
"provider.connect.console.retry": "Try again",
"provider.connect.console.refreshing": "OpenCode connected. Loading your models...",
"provider.connect.console.refreshFailed":
"Your account is connected, but we couldn't load your models. Try again to refresh them.",
"provider.connect.console.connected": "OpenCode connected",
"provider.connect.console.ready": "Your models are ready.",
"provider.connect.console.noModels":
"Your account is connected, but this Console workspace has no available models. Check its setup in Console, then refresh.",
"provider.connect.console.refresh": "Refresh models",
"provider.connect.console.model": "Model",
"provider.connect.console.start": "Start coding",
"provider.connect.console.done": "Done",
"provider.connect.console.continue": "Continue with OpenCode Console",
"provider.connect.console.openingBrowser": "Opening browser…",
"provider.connect.console.serviceAccount": "Service account?",
"provider.connect.console.useApiKey": "Use API key",
"provider.connect.console.otherMethods": "Other methods",
"provider.connect.console.serviceKey": "API key (service account)",
"provider.connect.console.serviceKeyDescription": "Connect using a service-account API key from OpenCode Console.",
"provider.connect.console.intro": "Sign in once to use the models available through your OpenCode account.",
"provider.connect.remote.title": "Connecting on “{{server}}”",
"provider.connect.remote.description":
"Your OpenCode credentials will be stored on this server. Models will be available through this server.",
"provider.connect.form.unsupported": "This authentication form contains unsupported fields",
"provider.setup.title": "Start with OpenCode",
"provider.setup.other": "Other providers",
"provider.setup.settings": "You can change this later in Settings → Providers.",
"provider.connect.title.anthropicProMax": "Login with Claude Pro/Max",
"provider.connect.selectMethod": "Select login method for {{provider}}.",
"provider.connect.method.apiKey": "API key",
@@ -1269,6 +1316,8 @@ export const dict = {
"settings.providers.section.connected": "Connected providers",
"settings.providers.connected.empty": "No connected providers",
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
"settings.providers.console.available.one": "{{count}} provider available",
"settings.providers.console.available.other": "{{count}} providers available",
"settings.providers.section.popular": "Popular providers",
"settings.providers.custom.description": "Add an OpenAI-compatible provider by base URL.",
"settings.providers.tag.environment": "Environment",
@@ -37,6 +37,9 @@ type PlatformBase = {
/** Open a web or mail URL in the default system application */
openExternal(url: string): void
/** Open an authentication page, reporting whether the browser could be launched. */
openBrowser?(url: string): Promise<boolean>
/** Open a local path in a local app (desktop only) */
openPath?(path: string, app?: string): Promise<void>
+26 -2
View File
@@ -4,7 +4,7 @@ import { Switch } from "@opencode/ui/switch"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { TextInput } from "@opencode/ui/text-input"
import { type Component, For, Show } from "solid-js"
import { createEffect, type Component, For, Show } from "solid-js"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { useLanguage } from "@/runtime/i18n/language"
@@ -25,7 +25,11 @@ export const ModelProvidersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
})
export const SettingsModels: Component = () => {
export const SettingsModels: Component<{
active?: boolean
provider?: string
onReveal?: () => void
}> = (props) => {
const language = useLanguage()
const models = useModels()
const serverSdk = useServerSDK()
@@ -34,6 +38,7 @@ export const SettingsModels: Component = () => {
ModelProvidersSchema,
{ collapsed: {} },
)
const sections = new Map<string, HTMLElement>()
const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(),
@@ -57,6 +62,24 @@ export const SettingsModels: Component = () => {
},
})
createEffect(() => {
if (!props.active || !props.provider) return
const provider = props.provider
if (list.filter()) {
list.clear()
return
}
if (!list.grouped.latest.some((group) => group.category === provider)) return
const section = sections.get(provider)
if (!section?.isConnected) return
list.grouped.latest.forEach((group) => setStore("collapsed", group.category, group.category !== provider))
requestAnimationFrame(() => {
section.scrollIntoView({ block: "start" })
section.querySelector<HTMLElement>(".settings-models-group-trigger")?.focus({ preventScroll: true })
props.onReveal?.()
})
})
return (
<>
<div class="settings-tab-header settings-tab-header--stacked">
@@ -121,6 +144,7 @@ export const SettingsModels: Component = () => {
return (
<div
ref={(element) => sections.set(group.category, element)}
class="settings-section"
data-component="settings-models-provider"
data-expanded={expanded() ? "" : undefined}
+188 -34
View File
@@ -1,11 +1,14 @@
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 { ProviderIcon } from "@opencode/ui/provider-icon"
import { OpenCodeLogo } from "@/providers/opencode-logo"
import { showToast } from "@/shell/notifications/toast"
import { popularProviders, useProviders } from "@/providers/catalog/providers"
import { useIntegrations } from "@/providers/catalog/integrations"
import { createMemo, type Component, For, Show } from "solid-js"
import { createEffect, createMemo, type Component, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog"
@@ -33,6 +36,7 @@ const PROVIDER_ICON_SIZE = 16
export const SettingsProviders: Component<{
directory: string | undefined
onBack?: () => void
onSelectProvider?: (providerID: string) => void
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
@@ -40,18 +44,35 @@ export const SettingsProviders: Component<{
const providers = useProviders(() => props.directory)
const integrations = useIntegrations(() => props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack })
const [state, setState] = createStore({
disconnecting: {} as Record<string, "removing" | "removed" | undefined>,
consoleExpanded: false,
})
const updateDisconnecting = (ids: string[], status: "removing" | "removed" | undefined) =>
setState("disconnecting", (current) => ({
...current,
...Object.fromEntries(ids.map((id) => [id, status])),
}))
const integration = (providerID: string) => integrations.list().find((item) => item.id === providerID)
const connect = (provider?: string) => {
providerConnect.select(provider)
void dialog.show(() => (
<SettingsServerScope directory={props.directory}>
<DialogConnectProvider directory={props.directory} controller={providerConnect} />
<DialogConnectProvider
directory={props.directory}
controller={providerConnect}
onConnected={(providerID) =>
setState("disconnecting", (current) =>
providerID === "opencode" ? {} : { ...current, [providerID]: undefined },
)
}
/>
</SettingsServerScope>
))
}
const connected = createMemo(() => {
const available = createMemo(() => {
return providers
.connected()
.filter(
@@ -61,6 +82,37 @@ export const SettingsProviders: Component<{
.toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go"))
})
createEffect(() => {
const ids = new Set(available().map((item) => item.id))
Object.entries(state.disconnecting).forEach(([id, status]) => {
if (status === "removing" && !ids.has(id)) setState("disconnecting", id, "removed")
})
})
const connected = createMemo(() => available().filter((item) => !state.disconnecting[item.id]))
const consoleGroup = createMemo(() => {
const root = available().find((item) => item.id === "opencode")
const suffix = " / OpenCode"
if (!root?.name.endsWith(suffix)) return
const workspace = root.name.slice(0, -suffix.length).trim()
if (!workspace) return
const prefix = `${workspace} / `
return {
root,
workspace,
providers: available().filter((item) => item.name.startsWith(prefix)),
prefix,
}
})
const displayed = createMemo(() => {
const group = consoleGroup()
if (!group) return connected()
const grouped = new Set(group.providers.filter((item) => item.id !== group.root.id).map((item) => item.id))
return connected().filter((item) => !grouped.has(item.id))
})
const popular = createMemo(() => {
const connectedIDs = new Set(connected().map((p) => p.id))
const items = providers
@@ -104,6 +156,10 @@ export const SettingsProviders: Component<{
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const disconnect = async (providerID: string, name: string) => {
if (state.disconnecting[providerID]) return
const group = consoleGroup()
const ids = group?.root.id === providerID ? group.providers.map((provider) => provider.id) : [providerID]
updateDisconnecting(ids, "removing")
const location = props.directory ? { directory: props.directory } : undefined
await serverSdk.api.integration
.get({ integrationID: providerID, location })
@@ -113,6 +169,7 @@ export const SettingsProviders: Component<{
await Promise.all(
credentials.map((credential) => serverSdk.api.credential.remove({ credentialID: credential.id, location })),
)
updateDisconnecting(ids, "removed")
showToast({
variant: "success",
icon: "circle-check",
@@ -121,6 +178,7 @@ export const SettingsProviders: Component<{
})
})
.catch((err: unknown) => {
updateDisconnecting(ids, undefined)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
@@ -143,38 +201,127 @@ export const SettingsProviders: Component<{
<h3 class="settings-section-title">{language.t("settings.providers.section.connected")}</h3>
<SettingsList>
<Show
when={connected().length > 0}
when={displayed().length > 0}
fallback={<div class="settings-provider-empty">{language.t("settings.providers.connected.empty")}</div>}
>
<For each={connected()}>
{(item) => (
<div class="settings-provider-row group">
<div class="settings-provider-lead">
<ProviderIcon
id={item.id}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-provider-icon shrink-0"
/>
<div class="settings-provider-main">
<span class="settings-provider-name truncate">{item.name}</span>
<Badge>{type(item)}</Badge>
</div>
</div>
<For each={displayed()}>
{(item) => {
const console = () => (consoleGroup()?.root.id === item.id ? consoleGroup() : undefined)
return (
<Show
when={canDisconnect(item)}
when={console()}
fallback={
<span class="settings-provider-env-hint">
{language.t("settings.providers.connected.environmentDescription")}
</span>
<div class="settings-provider-row group">
<div class="settings-provider-lead">
<Show
when={item.id === "opencode"}
fallback={
<ProviderIcon
id={item.id}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-provider-icon shrink-0"
/>
}
>
<OpenCodeLogo class="settings-provider-icon size-4 shrink-0" />
</Show>
<div class="settings-provider-main">
<span class="settings-provider-name truncate">{item.name}</span>
<Badge>{type(item)}</Badge>
</div>
</div>
<Show
when={canDisconnect(item)}
fallback={
<span class="settings-provider-env-hint">
{language.t("settings.providers.connected.environmentDescription")}
</span>
}
>
<Button
size="normal"
variant="ghost-muted"
onClick={() => void disconnect(item.id, item.name)}
>
{language.t("common.disconnect")}
</Button>
</Show>
</div>
}
>
<Button size="normal" variant="ghost-muted" onClick={() => void disconnect(item.id, item.name)}>
{language.t("common.disconnect")}
</Button>
{(group) => (
<div class="settings-provider-console group">
<div class="settings-provider-console-header">
<div class="settings-provider-lead">
<OpenCodeLogo class="settings-provider-icon size-4 shrink-0" />
<div class="settings-provider-console-summary">
<div class="settings-provider-main">
<span class="settings-provider-name truncate">
{language.t("provider.connect.console.name")}
</span>
<Badge>{group().workspace}</Badge>
</div>
<Show when={group().providers.length > 1}>
<button
type="button"
class="settings-provider-console-toggle"
aria-expanded={state.consoleExpanded}
onClick={() => setState("consoleExpanded", (value) => !value)}
>
<span>
{language.plural(
"settings.providers.console.available",
group().providers.length,
{ count: group().providers.length },
)}
</span>
<Icon
name="chevron-right"
size="small"
classList={{
"settings-provider-console-chevron": true,
open: state.consoleExpanded,
}}
/>
</button>
</Show>
</div>
</div>
<Button
size="normal"
variant="ghost-muted"
onClick={() => void disconnect(item.id, language.t("provider.connect.console.name"))}
>
{language.t("common.disconnect")}
</Button>
</div>
<Show when={state.consoleExpanded}>
<div class="settings-provider-console-list">
<div class="settings-provider-console-separator" aria-hidden="true" />
<For each={group().providers}>
{(provider) => (
<button
type="button"
class="settings-provider-console-item"
onClick={() => props.onSelectProvider?.(provider.id)}
>
<span>{provider.name.slice(group().prefix.length)}</span>
<Icon
name="chevron-right"
size="small"
class="settings-provider-console-item-chevron"
/>
</button>
)}
</For>
</div>
</Show>
</div>
)}
</Show>
</div>
)}
)
}}
</For>
</Show>
</SettingsList>
@@ -187,12 +334,19 @@ export const SettingsProviders: Component<{
{(item) => (
<div class="settings-provider-row">
<div class="settings-provider-lead">
<ProviderIcon
id={item.id}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-provider-icon shrink-0"
/>
<Show
when={item.id === "opencode"}
fallback={
<ProviderIcon
id={item.id}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-provider-icon shrink-0"
/>
}
>
<OpenCodeLogo class="settings-provider-icon size-4 shrink-0" />
</Show>
<div class="settings-provider-copy">
<div class="settings-provider-main">
<span class="settings-provider-name">{item.name}</span>
+134 -2
View File
@@ -515,6 +515,129 @@
border-bottom: none;
}
.settings-provider-console {
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-provider-console:last-child {
border-bottom: none;
}
.settings-provider-console-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 48px;
padding-block: 10px;
}
.settings-provider-console-summary {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.settings-provider-console-toggle {
display: flex;
height: 28px;
align-items: center;
gap: 2px;
padding-inline: 4px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
cursor: pointer;
}
@media (hover: hover) {
.settings-provider-console-toggle:hover {
background: var(--v2-overlay-simple-overlay-hover);
color: var(--v2-text-text-base);
}
}
.settings-provider-console-chevron {
color: var(--v2-icon-icon-muted);
transition: transform 150ms ease-out;
}
.settings-provider-console-chevron.open {
transform: rotate(90deg);
}
.settings-provider-console-list {
display: flex;
flex-direction: column;
gap: 2px;
padding-bottom: 10px;
}
.settings-provider-console-separator {
height: 0.5px;
margin-bottom: 6px;
background: var(--v2-border-border-base);
}
.settings-provider-console-item {
display: flex;
width: 100%;
min-height: 24px;
align-items: center;
gap: 2px;
padding-inline: 10px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
text-align: start;
cursor: pointer;
}
.settings-provider-console-item-chevron {
color: var(--v2-icon-icon-base);
opacity: 0;
}
@media (hover: hover) {
.settings-provider-console-item:hover {
background: var(--v2-overlay-simple-overlay-hover);
}
.settings-provider-console-item:hover .settings-provider-console-item-chevron {
opacity: 1;
}
}
.settings-provider-console-item:focus-visible {
background: var(--v2-overlay-simple-overlay-hover);
}
.settings-provider-console-item:focus-visible .settings-provider-console-item-chevron {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.settings-provider-console-chevron {
transition: none;
}
}
@container settings-panel (max-width: 520px) {
.settings-provider-console-header {
flex-wrap: wrap;
}
}
@media (min-width: 640px) {
.settings-provider-row {
flex-wrap: nowrap;
@@ -536,7 +659,7 @@
min-width: 0;
flex: 1;
align-items: flex-start;
gap: 10px;
gap: 8px;
}
.settings-provider-lead:not(:has(.settings-provider-copy)) {
@@ -575,7 +698,7 @@
}
.settings-provider-empty {
padding-block: 20px;
padding-block: 16px;
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
@@ -633,6 +756,15 @@
margin-top: 16px;
}
.settings-providers [data-component="connected-providers-section"] [data-component="settings-list"] {
padding-inline: 16px;
}
.settings-providers [data-component="connected-providers-section"] .settings-provider-row {
min-height: 48px;
padding-block: 10px;
}
.settings-tab-header.settings-tab-header--stacked {
display: flex;
flex-direction: column;
+17 -3
View File
@@ -55,7 +55,10 @@ export const SettingsScreen: Component = () => {
const servers = useServers()
const tabs = useTabs()
const global = useGlobal()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
const [state, setState] = createStore({
worktreeFilterReset: 0,
modelProvider: undefined as string | undefined,
})
let root: HTMLDivElement | undefined
onMount(() => {
@@ -228,10 +231,21 @@ export const SettingsScreen: Component = () => {
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={directory()} onBack={showProviders} />
<SettingsProviders
directory={directory()}
onBack={showProviders}
onSelectProvider={(providerID) => {
setState("modelProvider", providerID)
surface.open("models")
}}
/>
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels />
<SettingsModels
active={surface.tab() === "models"}
provider={state.modelProvider}
onReveal={() => setState("modelProvider", undefined)}
/>
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions />
@@ -1,4 +1,6 @@
import { Effect } from "effect"
import { shell } from "electron"
import { resolveExternalURL } from "../files/external-url"
import { FileRpcs } from "../../shared/ipc-rpc"
import { DesktopFiles, openExternalURL, openLocalFileURL } from "../files"
import { IpcPortHandoff } from "../ipc-transport"
@@ -18,13 +20,22 @@ export const fileHandlers = FileRpcs.toLayer(
)
.pipe(Effect.orDie),
FilesReadPickedFile: ({ token, path }, context) =>
files
.readPickedFile(sender(handoff, context).id, token, path)
.pipe(Effect.map((buffer) => new Uint8Array(buffer)), Effect.orDie),
files.readPickedFile(sender(handoff, context).id, token, path).pipe(
Effect.map((buffer) => new Uint8Array(buffer)),
Effect.orDie,
),
FilesReleasePickedFiles: ({ token }, context) =>
Effect.sync(() => files.releasePickedFiles(sender(handoff, context).id, token)),
FilesSaveFile: ({ options, content }) => files.saveFile(options, content).pipe(Effect.orDie),
FilesOpenExternal: ({ url }) => openExternalURL(url),
FilesOpenBrowser: ({ url }) => {
const target = resolveExternalURL(url)
if (!target || !/^https?:/.test(target)) return Effect.succeed(false)
return Effect.tryPromise(() => shell.openExternal(target)).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
)
},
FilesOpenLocalFile: ({ url }) => openLocalFileURL(url),
FilesOpenPath: ({ path, application }) =>
files.openPath(path, application).pipe(
@@ -0,0 +1,17 @@
import { describe, expect, test } from "bun:test"
import { consoleReturnWindow } from "./deep-link"
describe("Console return deep links", () => {
test("reads the originating Desktop window", () => {
expect(consoleReturnWindow("opencode://console/authorized?window=window-a")).toBe("window-a")
expect(consoleReturnWindow("opencode://console/authorized?window=window%20b")).toBe("window b")
})
test("rejects unrelated and malformed links", () => {
expect(consoleReturnWindow("opencode://console/other?window=window-a")).toBeUndefined()
expect(consoleReturnWindow("opencode://other/authorized?window=window-a")).toBeUndefined()
expect(consoleReturnWindow("https://console/authorized?window=window-a")).toBeUndefined()
expect(consoleReturnWindow("not a url")).toBeUndefined()
expect(consoleReturnWindow("opencode://console/authorized")).toBeUndefined()
})
})
@@ -0,0 +1,11 @@
export function consoleReturnWindow(value: string) {
try {
const url = new URL(value)
if (url.protocol !== "opencode:" || url.hostname !== "console" || url.pathname !== "/authorized") return
const id = url.searchParams.get("window")
if (!id || id.length > 256 || /[\u0000-\u001f\u007f]/.test(id)) return
return id
} catch {
return
}
}
+18 -8
View File
@@ -8,10 +8,11 @@ import { emitIpcEvent } from "../ipc-events"
import { DesktopLogging, scoped } from "../native/logging"
import { DesktopStorage } from "../storage"
import { safeWebContentsURL } from "../windows/state"
import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
import { getLastFocusedWindow, getWindowByID, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
import { acquireApplicationLock, configureApplication } from "./environment"
import { initializeFirstLaunchOnboarding } from "./onboarding"
import { Shutdown } from "./shutdown"
import { consoleReturnWindow } from "./deep-link"
export interface Interface {
readonly relaunch: () => void
@@ -34,11 +35,23 @@ const runtime = Layer.effect(
const pendingDeepLinks: string[] = []
let shutdownReady = false
const prepareToRestart = shutdown.run.pipe(Effect.ensuring(Effect.sync(() => (shutdownReady = true))))
const focusWindow = (win: BrowserWindow | null) => {
if (!win) return
if (win.isMinimized()) win.restore()
win.show()
win.focus()
}
const emitDeepLinks = (urls: string[]) => {
if (!urls.length) return
pendingDeepLinks.push(...urls)
const win = getLastFocusedWindow()
const target = urls.flatMap((url) => {
const id = consoleReturnWindow(url)
const win = id ? getWindowByID(id) : null
return win ? [win] : []
})[0]
const win = target ?? getLastFocusedWindow()
if (win) emitIpcEvent(win.webContents, new DeepLinksOpened({ urls }))
return win
}
const relaunch = () => {
setAppQuitting()
@@ -57,17 +70,14 @@ const runtime = Layer.effect(
const urls = argv.filter((arg) => arg.startsWith("opencode://"))
if (urls.length) {
runFork(Effect.logInfo("deep link received via second-instance", { urls }))
emitDeepLinks(urls)
focusWindow(emitDeepLinks(urls) ?? null)
}
const win = getLastFocusedWindow()
if (!win) return
win.show()
win.focus()
if (!urls.length) focusWindow(getLastFocusedWindow())
}
const openUrl = (event: Event, url: string) => {
event.preventDefault()
runFork(Effect.logInfo("deep link received via open-url", { url }))
emitDeepLinks([url])
focusWindow(emitDeepLinks([url]) ?? null)
}
const beforeQuit = (event: Event) => {
setAppQuitting()
@@ -70,6 +70,12 @@ export function getLastFocusedWindow() {
return win
}
export function getWindowByID(id: string) {
const win = registry.get(id)
if (!win || win.isDestroyed()) return null
return win
}
export function setWindowThemeReady(win: BrowserWindow) {
themeReady.get(win)?.()
}
@@ -25,6 +25,8 @@ describe("window registry", () => {
app.registry.register("a", { name: "a" })
app.registry.register("b", { name: "b" })
expect(app.state.stored).toEqual(["a", "b"])
expect(app.registry.get("a")).toEqual({ name: "a" })
expect(app.registry.get("missing")).toBeUndefined()
})
test("forgets a deliberately closed window while others remain open", () => {
@@ -1,9 +1,6 @@
// Tracks open windows and the persisted window id list used to restore
// windows (and their per-window persisted state) across app launches.
export function createWindowRegistry<W>(persistence: {
read: () => unknown
write: (ids: string[]) => void
}) {
export function createWindowRegistry<W>(persistence: { read: () => unknown; write: (ids: string[]) => void }) {
const windows = new Map<string, W>()
let quitting = false
let lastFocusedID: string | undefined
@@ -31,6 +28,9 @@ export function createWindowRegistry<W>(persistence: {
if (!lastFocusedID) return
return windows.get(lastFocusedID)
},
get(id: string) {
return windows.get(id)
},
closed(id: string) {
windows.delete(id)
if (lastFocusedID === id) lastFocusedID = windows.keys().next().value
@@ -63,6 +63,7 @@ export type ElectronAPI = {
getPathForFile(file: File): string
saveFile(opts: SaveFilePickerOptions, content: string): Promise<boolean>
openExternal(url: string): void
openBrowser(url: string): Promise<boolean>
openLocalFile(url: string): void
openPath(path: string, app?: string): Promise<string | undefined>
revealPath(path: string): Promise<boolean>
+1
View File
@@ -122,6 +122,7 @@ export const api: ElectronAPI = {
getPathForFile: (file) => window.electron.getPathForFile(file),
saveFile: (opts, content) => invoke("FilesSaveFile", { options: opts, content }),
openExternal: (url) => send("FilesOpenExternal", { url }),
openBrowser: (url) => invoke("FilesOpenBrowser", { url }),
openLocalFile: (url) => send("FilesOpenLocalFile", { url }),
openPath: (path, app) => invoke("FilesOpenPath", { path, application: app }).then((value) => value ?? undefined),
revealPath: (path) => invoke("FilesRevealPath", { path }),
@@ -21,6 +21,7 @@ function fileApi(events: string[]) {
getPathForFile: () => "fallback",
saveFile: async () => false,
openExternal: () => {},
openBrowser: async () => true,
openLocalFile: () => {},
resolveAppPath: async () => null,
openPath: async () => undefined,
@@ -33,6 +34,10 @@ function fileApi(events: string[]) {
}
describe("desktop attachment files", () => {
test("reports native browser launch failure to the renderer", async () => {
const files = createDesktopFiles({ ...fileApi([]), openBrowser: async () => false }, "macos", [])
expect(await files.openBrowser("https://opencode.ai/console")).toBe(false)
})
test("reads selected files sequentially and releases the token", async () => {
const events: string[] = []
const files = createDesktopFiles(fileApi(events), "windows", ["txt"])
@@ -11,6 +11,7 @@ type DesktopFileAPI = Pick<
| "getPathForFile"
| "saveFile"
| "openExternal"
| "openBrowser"
| "openLocalFile"
| "resolveAppPath"
| "openPath"
@@ -55,6 +56,7 @@ export function createDesktopFiles(api: DesktopFileAPI, os: DesktopOS, acceptedE
saveFile: (options: { title?: string; defaultPath?: string }, content: string) =>
api.saveFile({ title: options.title, defaultPath: options.defaultPath }, content),
openExternal: (url: string) => api.openExternal(url),
openBrowser: (url: string) => api.openBrowser(url),
openLocalFile: (url: string) => api.openLocalFile(url),
async openPath(path: string, app?: string) {
if (os !== "windows") {
@@ -42,6 +42,10 @@ export const FilesSaveFile = Rpc.make("FilesSaveFile", {
export const FilesOpenExternal = Rpc.make("FilesOpenExternal", {
payload: { url: Schema.String },
})
export const FilesOpenBrowser = Rpc.make("FilesOpenBrowser", {
payload: { url: Schema.String },
success: Schema.Boolean,
})
export const FilesOpenLocalFile = Rpc.make("FilesOpenLocalFile", {
payload: { url: Schema.String },
})
@@ -67,6 +71,7 @@ export const FileRpcs = RpcGroup.make(
FilesReleasePickedFiles,
FilesSaveFile,
FilesOpenExternal,
FilesOpenBrowser,
FilesOpenLocalFile,
FilesOpenPath,
FilesRevealPath,
+12 -1
View File
@@ -10,6 +10,7 @@ export interface DialogProps extends ParentProps {
containerClass?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
fit?: boolean
onCloseAutoFocus?: ComponentProps<typeof Content>["onCloseAutoFocus"]
}
export interface DialogHeaderProps extends ParentProps {
@@ -82,7 +83,16 @@ export function DialogHeader(props: DialogHeaderProps) {
}
export function Dialog(props: DialogProps) {
const [local] = splitProps(props, ["size", "variant", "class", "containerClass", "classList", "fit", "children"])
const [local] = splitProps(props, [
"size",
"variant",
"class",
"containerClass",
"classList",
"fit",
"children",
"onCloseAutoFocus",
])
return (
<div
@@ -94,6 +104,7 @@ export function Dialog(props: DialogProps) {
<div data-slot="dialog-container" class={local.containerClass}>
<Content
data-slot="dialog-content"
onCloseAutoFocus={local.onCloseAutoFocus}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,