mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-11 19:36:25 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1a3fad739 | ||
|
|
e249302f2c | ||
|
|
5245303289 | ||
|
|
d027b93b19 |
@@ -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")
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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/**",
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user