feat(app): sign in to OpenCode Console and Go through the device flow (#48501)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
usrnk1
2026-09-24 05:51:35 +02:00
committed by GitHub
parent b486652067
commit b7d707cd64
43 changed files with 2121 additions and 517 deletions
+8 -9
View File
@@ -3,7 +3,7 @@ import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { Icon } from "@opencode/ui/icon"
import { Keybind } from "@opencode/ui/keybind"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { ProviderModelIcon } from "@/providers/models/provider-group"
import { Tooltip } from "@opencode/ui/tooltip"
import { ComposerEditor } from "./editor/editor"
import { ModelSelectorPopover } from "@/providers/models/select-dialog"
@@ -35,7 +35,7 @@ export function Composer(props: { class?: string; model: ComposerModel; borderUn
title={language.t("command.model.choose")}
keybind={command.keybindParts("model.choose")}
model={props.model.model.selection}
providerID={props.model.model.selection.current()?.provider?.id}
provider={props.model.model.selection.current()?.provider}
modelName={props.model.model.selection.current()?.name ?? language.t("dialog.model.select.title")}
onClose={props.model.restoreFocus}
onUnpaidClick={() => dialog.show(() => <DialogSelectModelUnpaid model={props.model.model.selection} />)}
@@ -52,7 +52,7 @@ function ComposerModelControl(props: {
title: string
keybind: string[]
model: ComposerModel["model"]["selection"]
providerID?: string
provider?: { id: string; canonical?: string; name: string }
modelName: string
onClose: () => void
onUnpaidClick: () => void
@@ -60,12 +60,11 @@ function ComposerModelControl(props: {
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
const content = () => (
<>
<Show when={props.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
<Show when={props.provider}>
{(provider) => (
<ProviderModelIcon
provider={provider()}
class="shrink-0 opacity-40 transition-opacity duration-150 group-hover:opacity-100"
/>
)}
</Show>
+2 -2
View File
@@ -16,7 +16,7 @@ import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { ProviderModelIcon } from "@/providers/models/provider-group"
import { useI18n } from "@opencode/ui/context/i18n"
import { Button } from "@opencode/ui/button"
import { Keybind } from "@opencode/ui/keybind"
@@ -702,7 +702,7 @@ function ComposerEditorConfiguredSelect(props: {
current={current()}
currentIcon={
<Show when={props.model && providerID()}>
<ProviderIcon id={providerID()!} class="size-4 shrink-0 opacity-60" />
{(id) => <ProviderModelIcon provider={{ id: id(), name: id() }} class="shrink-0 opacity-60" />}
</Show>
}
onSelect={props.control.onSelect}
+12 -4
View File
@@ -152,6 +152,8 @@ export function NewSessionView(props: {
</div>
</div>
<NewSessionTips
selection={props.composer.model.selection}
onDone={props.composer.restoreFocus}
workspaceEligible={
!!props.project.selected() &&
props.workspace.bar.visible() &&
@@ -165,7 +167,12 @@ export function NewSessionView(props: {
)
}
function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => void }) {
function NewSessionTips(props: {
selection: ComposerModel["model"]["selection"]
onDone: () => void
workspaceEligible: boolean
onWorkspace: () => void
}) {
const language = useLanguage()
const dialog = useDialog()
const sdk = useWorkspaceLocation()
@@ -188,9 +195,8 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
)
const providerVisible = createMemo(
() =>
providers.ready() &&
providerReady() &&
providers.paid().length === 0 &&
providers.anyConnection() === false &&
Date.now() - providerState.dismissedAt >= providerTipDismissalDuration,
)
const tip = createMemo<"workspace" | "provider" | undefined>(() => {
@@ -212,7 +218,9 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
return
}
void import("@/providers/connect/dialog").then(({ DialogConnectProvider }) => {
void dialog.show(() => <DialogConnectProvider directory={sdk().directory} />)
void dialog.show(() => (
<DialogConnectProvider directory={sdk().directory} selection={props.selection} onDone={props.onDone} />
))
})
}
const dismiss = () => {
@@ -0,0 +1,19 @@
import { expect, test } from "bun:test"
import { consoleProviderGroup, consoleProviderName } from "./console"
test("groups only providers managed by the active Console workspace", () => {
const direct = { id: "openai", integrationID: "openai", name: "Anomaly / OpenAI" }
const group = consoleProviderGroup([
{ id: "opencode", integrationID: "opencode", name: "Anomaly / OpenCode" },
{ id: "console-openai", integrationID: "opencode", name: "Anomaly / OpenAI" },
{ id: "console-google", integrationID: "opencode", name: "Anomaly / Google" },
direct,
])
expect(group).toBeDefined()
if (!group) throw new Error("Expected Console provider group")
expect(group.workspace).toBe("Anomaly")
expect(group.providers.map((provider) => provider.id)).toEqual(["opencode", "console-openai", "console-google"])
expect(consoleProviderName(group, group.providers[1].name)).toBe("OpenAI")
expect(group.providers).not.toContain(direct)
})
@@ -0,0 +1,26 @@
type Provider = {
id: string
integrationID?: string
name: string
}
export function consoleProviderGroup<T extends Provider>(providers: readonly T[]) {
const root = providers.find((provider) => provider.id === "opencode" && provider.integrationID === "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,
prefix,
providers: providers.filter(
(provider) => provider.integrationID === "opencode" && provider.name.startsWith(prefix),
),
}
}
export function consoleProviderName(group: { prefix: string }, name: string) {
return name.startsWith(group.prefix) ? name.slice(group.prefix.length) : name
}
@@ -16,7 +16,13 @@ export function useIntegrations(directory: Accessor<string | undefined>) {
})().catch(() => undefined)
})
const location = () => {
const value = directory()
return value ? { directory: value } : undefined
}
return {
list: () => data.location.integration.list(directory() ? { directory: directory()! } : undefined) ?? [],
ready: () => data.location.integration.list(location()) !== undefined,
list: () => data.location.integration.list(location()) ?? [],
}
}
@@ -38,11 +38,13 @@ export function useProviders(directory: Accessor<string | undefined>) {
return normalizeProviderList(provider, model)
})
const ready = () => {
const ref = location()
return data.location.provider.list(ref) !== undefined && data.location.model.list(ref) !== undefined
}
return {
ready: () => {
const ref = location()
return data.location.provider.list(ref) !== undefined && data.location.model.list(ref) !== undefined
},
ready,
all: () => providers().all,
default: () => providers().default,
// V2 servers list only available providers, so the connectable catalog
@@ -70,6 +72,15 @@ export function useProviders(directory: Accessor<string | undefined>) {
(v) => Array.from(v),
)
},
// Any stored or detected connection, or a provider with models beyond the keyless OpenCode catalog.
// Undefined until both catalogs load, so callers never mistake loading for a first run.
anyConnection: () => {
if (!integrations.ready() || !ready()) return undefined
if (integrations.list().some((integration) => integration.connections.length > 0)) return true
return providers().connected.some(
(id) => id !== "opencode" && Object.keys(providers().all.get(id)?.models ?? {}).length > 0,
)
},
paid: () => {
const connected = new Set(providers().connected)
const paid = [
@@ -0,0 +1,82 @@
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: {
/** Undefined until the server returns the sign-in attempt. */
code: string | undefined
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>
<Show
when={props.code}
fallback={
<p role="status" class="flex h-12 items-center">
<TextShimmer text={language.t("provider.connect.console.openingBrowser")} active />
</p>
}
>
{(code) => (
<>
<div
dir="ltr"
role="group"
aria-label={language.t("provider.connect.console.deviceCode.label", { code: 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={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>
</>
)}
</Show>
</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" disabled={!props.code} 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>
)
}
@@ -23,12 +23,20 @@ export function consoleIntegration(provider: string) {
return CONSOLE_PROVIDERS.has(provider) ? CONSOLE_INTEGRATION : provider
}
function hiddenDefaults(method: ProviderConnectMethod | undefined) {
return Object.fromEntries(
(method?.form ?? []).flatMap((field) =>
field.type !== "external" && field.hidden && field.default !== undefined ? [[field.key, field.default]] : [],
),
) as FormAnswer
export function providerFormDefaults(fields: ProviderConnectMethod["form"]) {
return (fields ?? []).reduce<FormAnswer>((answer, field) => {
if (field.type === "external" || !field.hidden || field.default === undefined) return answer
const active = (field.when ?? []).every((condition) => {
const actual = answer[condition.key]
if (actual === undefined) return false
const equal = Array.isArray(actual)
? typeof condition.value === "string" && actual.includes(condition.value)
: actual === condition.value
return condition.op === "eq" ? equal : !equal
})
if (!active) return answer
return { ...answer, [field.key]: field.default }
}, {})
}
export function createProviderConnectionController(options: {
@@ -39,6 +47,8 @@ export function createProviderConnectionController(options: {
onComplete: () => void
/** Picks the method to start without asking when the integration exposes several. */
autoSelect?: (methods: ProviderConnectMethod[]) => number | undefined
/** Runs after the catalogs refresh; returning false keeps the dialog on a retryable error. */
prepare?: (active: () => boolean) => Promise<boolean>
pollInterval?: number
}) {
const language = useLanguage()
@@ -49,6 +59,7 @@ export function createProviderConnectionController(options: {
const directory = options.directory()
return directory ? { directory } : undefined
}
const isConsole = () => options.provider() === CONSOLE_INTEGRATION
// Not createResource: the dialog is owned by whichever page opened it, so reading a pending
// resource here would suspend that page's <Suspense> and blank the screen behind the dialog.
const [integration, setIntegration] = createStore({
@@ -85,9 +96,14 @@ export function createProviderConnectionController(options: {
formAnswer: undefined as FormAnswer | undefined,
// Nothing is in flight until a method is selected; `busy()` reads this, so a truthy initial
// value would keep multi-method providers on the spinner instead of the method list.
state: undefined as "pending" | "complete" | "error" | "form" | undefined,
state: undefined as "pending" | "waiting" | "refreshing" | "ready" | "error" | "form" | undefined,
error: undefined as string | undefined,
auto: false,
// The credential is stored; a retry only needs to reload the catalogs.
connected: false,
browserFailed: false,
// The attempt is still open on the server; a retry resumes polling it.
statusFailed: false,
})
const polling = {
generation: 0,
@@ -112,26 +128,21 @@ export function createProviderConnectionController(options: {
| { type: "auth.form" }
| { type: "auth.answer"; answer: FormAnswer | undefined }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: Authorization }
| { type: "auth.waiting"; authorization: Authorization }
| { type: "auth.error"; error: string }
const dispatch = (action: Action) => {
setStore(
produce((draft) => {
if (action.type === "method.select") {
draft.methodIndex = action.index
draft.authorization = undefined
draft.formAnswer = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "method.reset") {
draft.methodIndex = undefined
if (action.type === "method.select" || action.type === "method.reset") {
draft.methodIndex = action.type === "method.select" ? action.index : undefined
draft.authorization = undefined
draft.formAnswer = undefined
draft.state = undefined
draft.error = undefined
draft.connected = false
draft.browserFailed = false
draft.statusFailed = false
return
}
if (action.type === "auth.form") {
@@ -150,8 +161,8 @@ export function createProviderConnectionController(options: {
draft.error = undefined
return
}
if (action.type === "auth.complete") {
draft.state = "complete"
if (action.type === "auth.waiting") {
draft.state = "waiting"
draft.authorization = action.authorization
draft.error = undefined
return
@@ -162,6 +173,7 @@ export function createProviderConnectionController(options: {
)
}
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error))
const cancelAttempt = () => {
const attempt = polling.attempt
polling.attempt = undefined
@@ -179,16 +191,28 @@ export function createProviderConnectionController(options: {
const finish = async () => {
cancelPolling()
polling.attempt = undefined
const generation = polling.generation
const active = () => !polling.disposed && 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 (!active()) return
const prepared = refreshed && options.prepare ? await options.prepare(active) : refreshed
if (!active()) return
if (!prepared && options.prepare) {
dispatch({ type: "auth.error", error: language.t("provider.connect.console.refreshFailed") })
return
}
setStore("state", "ready")
options.onComplete()
}
const poll = async (authorization: Authorization, generation: number) => {
@@ -202,10 +226,10 @@ export function createProviderConnectionController(options: {
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
polling.attempt = undefined
setStore("statusFailed", true)
dispatch({
type: "auth.error",
error: result.error instanceof Error ? result.error.message : String(result.error),
error: isConsole() ? language.t("provider.connect.console.statusFailed") : errorMessage(result.error),
})
return
}
@@ -215,19 +239,41 @@ export function createProviderConnectionController(options: {
}
if (result.status.status === "failed") {
polling.attempt = undefined
dispatch({ type: "auth.error", error: result.status.message })
const message = result.status.message
dispatch({
type: "auth.error",
error:
isConsole() && message.includes("expired_token")
? language.t("provider.connect.console.expired")
: isConsole() && message.includes("access_denied")
? language.t("provider.connect.console.denied")
: message,
})
return
}
if (result.status.status === "expired") {
polling.attempt = undefined
dispatch({ type: "auth.error", error: language.t("provider.connect.oauth.expired") })
dispatch({
type: "auth.error",
error: language.t(isConsole() ? "provider.connect.console.expired" : "provider.connect.oauth.expired"),
})
return
}
polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000)
}
const open = () => {
const open = async () => {
const url = store.authorization?.url
if (url) platform.openExternal(url)
if (!url) return
const generation = polling.generation
const opened = await Promise.resolve()
.then(() => {
if (platform.openBrowser) return platform.openBrowser(url)
platform.openExternal(url)
return true
})
.catch(() => false)
if (polling.disposed || generation !== polling.generation) return
setStore("browserFailed", !opened)
}
const select = async (index: number, answer?: FormAnswer) => {
cancelPolling()
@@ -240,14 +286,14 @@ export function createProviderConnectionController(options: {
dispatch({ type: "auth.form" })
return
}
const merged = { ...hiddenDefaults(selected), ...answer }
const merged = { ...providerFormDefaults(selected.form), ...answer }
if (selected.type === "key") {
dispatch({ type: "auth.answer", answer: Object.keys(merged).length ? merged : undefined })
return
}
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" })
@@ -259,9 +305,11 @@ export function createProviderConnectionController(options: {
location: location(),
})
.then((response) => {
if (options.provider() === CONSOLE_INTEGRATION && platform.platform === "desktop") {
if (isConsole() && platform.platform === "desktop") {
const url = new URL(response.data.url)
url.searchParams.set("client_id", "opencode-desktop")
// Lets the Console return link focus the window that started the sign-in.
url.searchParams.set("return_window", platform.windowID)
response.data.url = url.href
}
return { ok: true as const, authorization: response.data }
@@ -281,21 +329,28 @@ export function createProviderConnectionController(options: {
if (!result.ok) {
dispatch({
type: "auth.error",
error: result.error instanceof Error ? result.error.message : String(result.error),
error: isConsole() ? language.t("provider.connect.console.startFailed") : errorMessage(result.error),
})
return
}
polling.attempt = result.authorization
dispatch({ type: "auth.complete", authorization: result.authorization })
dispatch({ type: "auth.waiting", authorization: result.authorization })
// Same as `opencode auth login`: hand the user straight to the browser instead of
// asking them to click a link and retype a code.
platform.openExternal(result.authorization.url)
void open()
if (result.authorization.mode === "auto") void poll(result.authorization, generation)
}
const retry = () => {
const retry = async () => {
if (store.connected) return finish()
const authorization = store.authorization
if (store.statusFailed && authorization) {
polling.attempt = authorization
setStore({ state: "waiting", error: undefined, statusFailed: false })
return poll(authorization, polling.generation)
}
const index = store.methodIndex
if (index === undefined) return
void select(index, store.formAnswer)
return select(index, store.formAnswer)
}
const reset = () => {
cancelPolling()
@@ -323,10 +378,7 @@ export function createProviderConnectionController(options: {
})
.then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (!result.ok) {
const message = result.error instanceof Error ? result.error.message : String(result.error)
return message || language.t("provider.connect.oauth.code.invalid")
}
if (!result.ok) return errorMessage(result.error) || language.t("provider.connect.oauth.code.invalid")
await finish()
return undefined
}
@@ -350,6 +402,7 @@ export function createProviderConnectionController(options: {
currentMethod,
methodIndex: () => store.methodIndex,
authorization: () => store.authorization,
browserFailed: () => store.browserFailed,
// True while nothing useful can be shown yet: the integration is loading, a method is
// about to be picked automatically, or the authorization request is in flight.
busy: () =>
@@ -363,6 +416,7 @@ export function createProviderConnectionController(options: {
reset,
retry,
open,
refresh: finish,
connectKey,
completeCode,
},
+526 -141
View File
@@ -1,55 +1,91 @@
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 { List } from "@opencode/ui/list"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { Spinner } from "@opencode/ui/spinner"
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, type JSX, Match, onMount, Show, Switch } from "solid-js"
import {
type Component,
createEffect,
createMemo,
createUniqueId,
For,
type JSX,
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"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { useProviders } from "@/providers/catalog/providers"
import { consoleProviderGroup, consoleProviderName } from "@/providers/catalog/console"
import { useIntegrations } from "@/providers/catalog/integrations"
import { CustomProviderForm } from "@/providers/credentials/dialog"
import { ProviderModelGroup, ProviderModelIcon } from "@/providers/models/provider-group"
import type { ModelSelection } from "@/providers/models/selection"
import { OpenCodeLogo } from "@/providers/opencode-logo"
import { decode64 } from "@/runtime/persistence/base64"
import { SettingsList } from "@/settings/list"
import { useTabs } from "@/shell/tabs/tabs"
import {
CONSOLE_INTEGRATION,
CONSOLE_PROVIDERS,
consoleIntegration,
createProviderConnectionController,
providerFormDefaults,
type ProviderConnectMethod,
} from "./controller"
import { ConsoleAuthorization } from "./console"
import { authServerName, RemoteAuthNotice } from "./remote"
import "./models.css"
const CUSTOM_ID = "_custom"
type IntegrationForm = NonNullable<ProviderConnectMethod["form"]>[number]
type StringForm = Extract<IntegrationForm, { type: "string" }>
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
export function useProviderConnectController() {
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const reset = () => setStore("selected", undefined)
return {
selected: () => store.selected,
select: (provider?: string) => setStore("selected", provider),
back: options.onBack ?? reset,
reset,
}
}
export const DialogConnectProvider: Component<{
directory?: string
/** Connects at the server's default Location instead of the current route's directory. */
defaultLocation?: boolean
controller?: ReturnType<typeof useProviderConnectController>
selection?: ModelSelection
onDone?: () => void
onConnected?: (provider: string) => void
}> = (props) => {
const fallback = useProviderConnectController()
const controller = props.controller ?? fallback
const [state, setState] = createStore({
completed: false,
modelProvider: undefined as { id: string; name: string } | undefined,
authorization: false,
})
const language = useLanguage()
const reset = controller.back
const reset = controller.reset
const back = { current: reset }
const consoleSelected = () => CONSOLE_PROVIDERS.has(controller.selected() ?? "")
let focusHost: HTMLDivElement | undefined
const holdFocus = () => focusHost?.focus({ preventScroll: true })
const select = (provider?: string) => {
@@ -63,13 +99,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}
defaultLocation={props.defaultLocation}
onBack={reset}
setBack={(handler) => (back.current = handler)}
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>
@@ -82,25 +127,53 @@ export const DialogConnectProvider: Component<{
return (
<Dialog
containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
preventBackdropDismiss={state.authorization}
containerClass={
state.modelProvider
? "!h-[min(calc(100vh_-_16px),560px)] !w-[min(calc(100vw_-_16px),640px)]"
: consoleSelected() && 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": consoleSelected() && !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">
<ProviderModelIcon provider={provider()} class="shrink-0" />
<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 || consoleSelected() ? "pb-0" : "pb-2"}`}
>
<div ref={focusHost} tabIndex={-1} class="flex min-h-0 flex-1 flex-col outline-none">
<Content />
</div>
@@ -119,10 +192,27 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
})
const featured = ["opencode-go", "opencode", "anthropic", "openai", "google", "openrouter", "vercel"]
const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
// Only a stored credential hides a provider: environment and config connections can still be
// replaced by a sign-in. OpenCode Zen stays until a Console account (not a key) is connected.
const consoleAccount = createMemo(() =>
integrations
.list()
.find((integration) => integration.id === CONSOLE_INTEGRATION)
?.connections.some((connection) => connection.type === "credential" && connection.method === "oauth"),
)
const all = createMemo(() => {
language.locale()
const query = store.filter.trim().toLowerCase()
const values = [custom(), ...integrations.list()]
const values = [
custom(),
...integrations
.list()
.filter((integration) =>
integration.id === CONSOLE_INTEGRATION
? !consoleAccount()
: !integration.connections.some((connection) => connection.type === "credential"),
),
]
if (!query) return values
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
})
@@ -212,9 +302,9 @@ 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" />
<ProviderModelIcon provider={provider} class="shrink-0 text-v2-icon-icon-base" />
<span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
<Show when={CONSOLE_PROVIDERS.has(provider.id)}>
<span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
{language.t(
provider.id === "opencode"
@@ -259,48 +349,81 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
function ProviderConnection(props: {
provider: string
directory?: string
defaultLocation?: boolean
onBack: () => void
setBack: (handler: () => void) => void
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 platform = usePlatform()
const sdk = useServerSDK()
const data = useData()
const global = useGlobal()
const providers = useProviders(() => props.directory)
const integrations = useIntegrations(() => props.directory)
const directory = () => props.directory ?? decode64(params.dir)
const location = () => {
const value = directory()
return value ? { directory: value } : undefined
}
const integrationID = () => consoleIntegration(props.provider)
const isConsole = () => CONSOLE_PROVIDERS.has(props.provider)
const tabs = useTabs()
// A sign-in belongs to the Location where it began, even if the route changes underneath it.
const initialDirectory = props.defaultLocation ? undefined : (props.directory ?? decode64(params.dir))
const directory = () => initialDirectory
const location = () => (initialDirectory ? { directory: initialDirectory } : undefined)
const providers = useProviders(directory)
const integrations = useIntegrations(directory)
const integrationID = consoleIntegration(props.provider)
const isConsole = CONSOLE_PROVIDERS.has(props.provider)
const remote = isConsole && authServerName(sdk.server) !== undefined
const [state, setState] = createStore({
copied: false,
copyFailed: false,
firstConnection: undefined as boolean | undefined,
models: false,
noModels: false,
// The workspace providers had not loaded when the wait ran out.
catalogPending: false,
selectedModel: "",
collapsed: {} as Record<string, boolean>,
})
const controller = createProviderConnectionController({
provider: integrationID,
provider: () => integrationID,
// A Go service-account key still belongs to the `opencode-go` integration (zen/go/v1),
// exactly as before; only the sign-in is shared with the Console.
keyProvider: () => props.provider,
directory,
autoSelect: (methods) => {
if (!isConsole()) return undefined
if (!isConsole) return undefined
const index = methods.findIndex((method) => method.type === "oauth")
return index === -1 ? undefined : index
},
prepare: isConsole ? prepareConsoleCatalog : undefined,
pollInterval: isConsole ? 500 : undefined,
onComplete: () => {
props.onConnected?.()
// The picker only lists the newest model per family by default, which hides most of
// what a new connection just unlocked. Show everything the connected integration offers.
// Console sign-in returns Go models inside the `opencode` provider, so this covers Go too.
const linked = (data.location.provider.list(location()) ?? []).filter(
(item) => item.id === props.provider || item.integrationID === integrationID(),
)
const ids = new Set(linked.map((item) => item.id))
global.models.show(
(data.location.model.list(location()) ?? [])
.filter((model) => ids.has(model.providerID) && model.status !== "deprecated")
.map((model) => ({ providerID: model.providerID, modelID: model.id })),
connectionModels().map((model) => ({ providerID: model.providerID, modelID: model.id })),
)
if (state.catalogPending) {
setState("noModels", true)
return
}
if (state.firstConnection) {
const first = connectionGroups()[0]?.models[0]
if (first) {
setState({ models: true, selectedModel: modelKey(first) })
props.onFirstConnection({ id: props.provider, name: provider().name })
return
}
// Keep the "connected, but no models" state visible so the workspace can be fixed.
if (isConsole) {
setState("noModels", true)
return
}
}
dialog.close()
showToast({
variant: "success",
@@ -310,6 +433,75 @@ function ProviderConnection(props: {
})
},
})
// Captured before the new credential lands, so the connection itself never counts as existing.
createEffect(() => {
if (state.firstConnection !== undefined) return
const existing = providers.anyConnection()
if (existing === undefined) return
setState("firstConnection", !existing)
})
const connectionProviders = createMemo(() =>
(data.location.provider.list(location()) ?? []).filter(
(provider) => provider.id === props.provider || provider.integrationID === integrationID,
),
)
const connectionModels = createMemo(() => {
const ids = new Set(connectionProviders().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 models = connectionModels()
return connectionProviders()
.map((provider) => ({ provider, models: models.filter((model) => model.providerID === provider.id) }))
.filter((group) => group.models.length > 0)
})
const managedProviders = createMemo(() => (isConsole ? consoleProviderGroup(connectionProviders()) : undefined))
// The server loads the Console workspace's providers after the grant lands, so the first refresh
// can still show only the free catalog. Poll briefly for the workspace providers before moving on.
async function prepareConsoleCatalog(active: () => boolean) {
if (controller.currentMethod()?.type === "key") return active()
const loaded = () =>
managedProviders() !== undefined || connectionProviders().some((provider) => provider.id !== "opencode")
const deadline = Date.now() + 10_000
while (!loaded() && active() && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 250))
if (!active()) return false
data.location.provider.invalidate(location())
data.location.model.invalidate(location())
await Promise.all([data.location.provider.sync(location()), data.location.model.sync(location())]).catch(
() => undefined,
)
}
setState("catalogPending", !loaded())
return active()
}
const connectionGroupName = (name: string) => {
const managed = managedProviders()
return managed ? consoleProviderName(managed, name) : name
}
const modelKey = (model: { providerID: string; id: string }) => `${model.providerID}:${model.id}`
const selectedModel = () => connectionModels().find((model) => modelKey(model) === state.selectedModel)
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
setState({ copied, copyFailed: !copied })
}
createEffect(() => {
controller.authorization()?.attemptID
setState({ copied: false, copyFailed: false })
})
createEffect(() => {
const current = controller.auth.state()
props.onAuthorization(controller.authorization() !== undefined && (current === "waiting" || current === "refreshing"))
})
const provider = createMemo(() => ({
id: props.provider,
name:
@@ -339,16 +531,30 @@ function ProviderConnection(props: {
}
}
const code = createMemo(() => {
const instructions = controller.authorization()?.instructions
const authorization = controller.authorization()
if (!authorization) return
const userCode = new URL(authorization.url).searchParams.get("user_code")
if (userCode) return userCode
const instructions = authorization.instructions
if (instructions?.includes(":")) return instructions.split(":").pop()?.trim()
return instructions
})
const keyIndex = () => controller.methods().findIndex((method) => method.type === "key")
const oauthIndex = () => controller.methods().findIndex((method) => method.type === "oauth")
// The Console device flow owns the dialog from the first frame until the catalogs are loaded.
const consoleSignIn = () =>
isConsole &&
!state.noModels &&
controller.currentMethod()?.type !== "key" &&
controller.auth.state() !== "error" &&
(controller.busy() || controller.authorization()?.mode === "auto")
function AuthFormView() {
const defaults = providerFormDefaults(controller.currentMethod()?.form)
const [formStore, setFormStore] = createStore({
value: {} as Record<string, string>,
value: Object.fromEntries(
Object.entries(defaults).flatMap(([key, value]) => (typeof value === "string" ? [[key, value]] : [])),
) as Record<string, string>,
index: 0,
})
@@ -471,11 +677,11 @@ function ProviderConnection(props: {
function goBack() {
// The API key path for the Console is an escape hatch below the sign-in flow, so
// "back" returns to the sign-in rather than leaving the provider.
if (isConsole() && controller.currentMethod()?.type === "key" && oauthIndex() !== -1) {
if (isConsole && controller.currentMethod()?.type === "key" && oauthIndex() !== -1) {
void controller.auth.select(oauthIndex())
return
}
if (!isConsole() && controller.methods().length > 1 && controller.methodIndex() !== undefined) {
if (!isConsole && controller.methods().length > 1 && controller.methodIndex() !== undefined) {
controller.auth.reset()
return
}
@@ -531,10 +737,12 @@ function ProviderConnection(props: {
<div class="flex items-start gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
<Icon name="circle-ban-sign" size="small" class="mt-0.5 shrink-0 text-v2-state-fg-danger" />
<span role="alert">
{language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}
{isConsole
? controller.auth.error()
: language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}
</span>
</div>
<Button variant="neutral" onClick={() => controller.auth.retry()}>
<Button variant="neutral" onClick={() => void controller.auth.retry()}>
{language.t("common.retry")}
</Button>
</div>
@@ -572,7 +780,7 @@ function ProviderConnection(props: {
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<Show
when={isConsole()}
when={isConsole}
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
>
<div>
@@ -586,7 +794,7 @@ function ProviderConnection(props: {
</div>
</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">
<label class="flex w-full flex-col gap-2 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
<TextInput
ref={apiKey}
@@ -648,11 +856,11 @@ 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">
<div>{language.t("provider.connect.oauth.code.description", { provider: provider().name })}</div>
<Button variant="neutral" icon="arrow-up-right" onClick={() => controller.auth.open()}>
<Button variant="neutral" icon="arrow-up-right" onClick={() => void controller.auth.open()}>
{language.t("provider.connect.oauth.openBrowser")}
</Button>
<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}
@@ -688,7 +896,7 @@ function ProviderConnection(props: {
<div>{language.t("provider.connect.oauth.auto.description", { provider: provider().name })}</div>
<StatusRow>{language.t("provider.connect.status.waiting")}</StatusRow>
<div class="flex flex-wrap items-center gap-2">
<Button variant="neutral" icon="arrow-up-right" onClick={() => controller.auth.open()}>
<Button variant="neutral" icon="arrow-up-right" onClick={() => void controller.auth.open()}>
{language.t("provider.connect.oauth.openBrowser")}
</Button>
</div>
@@ -708,109 +916,286 @@ function ProviderConnection(props: {
)
}
// Rendered from the first frame of the Console sign-in, before the attempt exists. Only the
// status line changes once the browser opens, so nothing around it moves.
function ConsoleSignInView() {
const ready = () => controller.authorization()?.url !== undefined
// Deliberately quiet: most people should never need a key, so this stays small and at the bottom.
function ConsoleApiKeySwitch() {
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>{language.t("provider.connect.console.description", { provider: provider().name })}</div>
<StatusRow>
{language.t(ready() ? "provider.connect.status.waiting" : "provider.connect.console.opening")}
</StatusRow>
<div class="flex flex-wrap items-center gap-2">
<Button variant="neutral" icon="arrow-up-right" disabled={!ready()} onClick={() => controller.auth.open()}>
{language.t("provider.connect.oauth.openBrowser")}
</Button>
</div>
<TextField
label={language.t("provider.connect.oauth.auto.confirmationCode")}
description={language.t("provider.connect.oauth.auto.confirmationCode.description")}
class="font-mono"
value={code() ?? ""}
placeholder={language.t("provider.connect.console.code.placeholder")}
readOnly
copyable={code() !== undefined}
/>
<div data-component="console-service-account" class="flex h-7 items-center gap-1 px-3 pt-5 text-[13px]">
<span class="text-v2-text-text-faint">{language.t("provider.connect.console.serviceAccount")}</span>
<Button
variant="ghost-muted"
data-action="provider-connect-api-key"
onClick={() => void controller.auth.select(keyIndex())}
>
{language.t("provider.connect.console.useApiKey")}
</Button>
</div>
)
}
// Deliberately quiet: most people should never need a key, so this stays small and at the bottom.
function ConsoleApiKeySwitch() {
function ConsoleNoModels() {
return (
<div class="mt-auto flex justify-end px-3 pt-4 text-[11px] leading-4 text-v2-text-text-muted">
<button
type="button"
data-action="provider-connect-api-key"
class="rounded-xs px-1 underline decoration-v2-border-border-base underline-offset-2 hover:text-v2-text-text-base focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
onClick={() => void controller.auth.select(keyIndex())}
<div role="status" class="flex flex-col items-start gap-5 px-3 text-[13px] leading-5 text-v2-text-text-muted">
<div>
<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(
state.catalogPending ? "provider.connect.console.modelsLoading" : "provider.connect.console.noModels",
)}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Show when={!state.catalogPending}>
<Button onClick={() => platform.openExternal("https://opencode.ai/console")}>
{language.t("provider.connect.console.openAgain")}
</Button>
</Show>
<Button
disabled={controller.auth.state() === "refreshing"}
aria-busy={controller.auth.state() === "refreshing"}
onClick={() => void controller.auth.refresh()}
>
{language.t("provider.connect.console.refresh")}
</Button>
</div>
</div>
)
}
const startWithModel = async () => {
const model = selectedModel()
if (!model) return
const selection = { providerID: model.providerID, modelID: model.id }
if (props.selection) {
props.selection.set(selection)
props.onDone?.()
dialog.close()
return
}
dialog.close()
await tabs.newDraft(
{
server: ServerConnection.key(sdk.server),
directory: initialDirectory ?? data.location.default().directory,
},
undefined,
selection,
)
}
function ConnectionModelList(listProps: { items: ReturnType<typeof connectionModels> }) {
return (
<SettingsList>
<For each={listProps.items}>
{(model) => {
const selected = () => state.selectedModel === modelKey(model)
return (
<div data-component="connected-model-row-shell" class="connected-model-row-shell">
<button
type="button"
role="radio"
data-component="settings-row"
data-first-provider-model=""
data-selected={selected() ? "" : undefined}
aria-checked={selected()}
class="connected-model-row text-start focus-visible:outline-none"
onClick={() => setState("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>
</div>
)
}}
</For>
</SettingsList>
)
}
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"
>
{language.t("provider.connect.console.apiKey.switch")}
</button>
<div data-component="available-models-heading" class="flex items-center gap-1.5">
<span class="text-[13px] font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.models.available")}
</span>
<Show when={managedProviders()}>{(managed) => <Badge>{managed().workspace}</Badge>}</Show>
</div>
<div role="radiogroup" aria-label={language.t("provider.connect.models.list", { provider: provider().name })}>
<Show
when={managedProviders()}
fallback={
<Show
when={connectionGroups().length > 1}
fallback={<ConnectionModelList items={connectionGroups()[0]?.models ?? []} />}
>
<For each={connectionGroups()}>
{(group) => {
const expanded = () => !state.collapsed[group.provider.id]
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">
<button
type="button"
class="settings-models-group-trigger"
aria-expanded={expanded()}
onClick={() => setState("collapsed", group.provider.id, expanded())}
>
<span class="settings-models-group-chevron">
<Icon name="chevron-down" size="small" classList={{ collapsed: !expanded() }} />
</span>
<span class="settings-models-group-label">
<ProviderModelIcon provider={group.provider} class="shrink-0" />
<span class="settings-section-title">{group.provider.name}</span>
</span>
</button>
</h3>
<Show when={expanded()}>
<ConnectionModelList items={group.models} />
</Show>
</section>
)
}}
</For>
</Show>
}
>
<div class="provider-model-groups provider-model-groups--dialog">
<For each={connectionGroups()}>
{(group) => (
<ProviderModelGroup
provider={group.provider}
name={connectionGroupName(group.provider.name)}
expanded={!state.collapsed[group.provider.id]}
onExpandedChange={(value) => setState("collapsed", group.provider.id, !value)}
>
<ConnectionModelList items={group.models} />
</ProviderModelGroup>
)}
</For>
</div>
</Show>
</div>
</div>
<div
data-component="first-provider-model-footer"
class="-mx-5 flex h-15 shrink-0 items-center justify-end border-t border-v2-border-border-muted px-4"
>
<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={!state.models} fallback={<FirstConnectionModels />}>
<div class="flex min-h-0 flex-1 flex-col">
<Switch>
<Match
<div
class={isConsole ? "flex shrink-0 items-center gap-2 px-3 pb-6" : "flex h-10 shrink-0 items-start gap-2 px-3"}
>
<ProviderModelIcon
provider={provider()}
class={isConsole ? "shrink-0 text-v2-icon-icon-base" : "mt-0.5 shrink-0 text-v2-icon-icon-base"}
/>
<div class="text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base">
<DialogTitle>
<Switch>
<Match when={consoleSignIn()}>{language.t("provider.connect.console.title")}</Match>
<Match
when={
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>
</DialogTitle>
</div>
</div>
<div
data-component="provider-connect-content"
class={isConsole ? "flex min-h-0 flex-1 flex-col overflow-y-auto pb-4" : "flex min-h-0 flex-1 flex-col"}
>
<Show when={remote}>
<div class="mb-5 px-3">
<RemoteAuthNotice server={sdk.server} />
</div>
</Show>
<Switch>
<Match when={state.noModels && controller.auth.state() !== "error"}>
<ConsoleNoModels />
</Match>
<Match when={consoleSignIn()}>
<div class="px-3">
<ConsoleAuthorization
code={code()}
browserFailed={controller.browserFailed()}
copied={state.copied}
copyFailed={state.copyFailed}
onCopy={() => void copyLink()}
onOpen={() => void controller.auth.open()}
/>
</div>
</Match>
<Match when={controller.busy()}>
<div class="px-3">
<StatusRow>{language.t("provider.connect.status.inProgress")}</StatusRow>
</div>
</Match>
<Match when={controller.methodIndex() === undefined}>
<MethodSelection />
</Match>
<Match when={controller.auth.state() === "form"}>
<AuthFormView />
</Match>
<Match when={controller.auth.state() === "error"}>
<div class="px-3">
<ErrorRow />
</div>
</Match>
<Match when={controller.currentMethod()?.type === "key"}>
<ApiAuthView />
</Match>
<Match when={controller.authorization()?.mode === "code"}>
<OAuthCodeView />
</Match>
<Match when={controller.authorization()?.mode === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
<Show
when={
isConsole() &&
isConsole &&
!controller.loading() &&
!state.noModels &&
controller.currentMethod()?.type !== "key" &&
controller.auth.state() !== "error" &&
(controller.busy() || controller.authorization()?.mode === "auto")
keyIndex() !== -1
}
>
<ConsoleSignInView />
</Match>
<Match when={controller.busy()}>
<div class="px-3">
<StatusRow>{language.t("provider.connect.status.inProgress")}</StatusRow>
</div>
</Match>
<Match when={controller.methodIndex() === undefined}>
<MethodSelection />
</Match>
<Match when={controller.auth.state() === "form"}>
<AuthFormView />
</Match>
<Match when={controller.auth.state() === "error"}>
<div class="px-3">
<ErrorRow />
</div>
</Match>
<Match when={controller.currentMethod()?.type === "key"}>
<ApiAuthView />
</Match>
<Match when={controller.authorization()?.mode === "code"}>
<OAuthCodeView />
</Match>
<Match when={controller.authorization()?.mode === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
<Show
when={isConsole() && !controller.loading() && controller.currentMethod()?.type !== "key" && keyIndex() !== -1}
>
<ConsoleApiKeySwitch />
</Show>
<ConsoleApiKeySwitch />
</Show>
</div>
</div>
</div>
</Show>
)
}
@@ -0,0 +1,72 @@
.connected-model-row-shell {
position: relative;
margin-inline: 4px;
padding-block: 4px;
}
.connected-model-row-shell:not(:last-child)::after {
content: "";
position: absolute;
inset-inline: 12px;
bottom: 0;
height: 0.5px;
background: var(--v2-border-border-base);
}
.connected-model-row {
display: flex;
width: 100%;
height: 40px;
align-items: center;
padding-inline: 12px;
padding-block: 0;
border: 0;
border-radius: 4px;
}
@media (hover: hover) {
.connected-model-row:hover {
background-color: var(--v2-background-bg-layer-03);
}
}
.connected-model-row:focus-visible {
background-color: var(--v2-background-bg-layer-03);
}
[data-component="first-provider-models"] [data-component="settings-list"] {
background-color: var(--v2-background-bg-layer-02);
overflow: hidden;
padding-inline: 0;
}
[data-component="first-provider-models"] .provider-model-groups--dialog .provider-model-group {
background-color: var(--v2-background-bg-layer-02);
}
[data-component="first-provider-models"] .provider-model-groups--dialog .provider-model-group-models {
padding-inline: 0;
}
[data-component="first-provider-models"]
.provider-model-groups--dialog
.provider-model-group-models
> [data-component="settings-list"] {
background-color: transparent;
}
@media (hover: hover) {
[data-component="first-provider-models"]
.provider-model-groups--dialog
.provider-model-group-trigger:not(:disabled):hover {
background-color: var(--v2-background-bg-layer-03);
}
}
[data-component="first-provider-models"] .settings-section {
gap: 8px;
}
[data-component="first-provider-models"] .settings-section[data-expanded] {
padding-bottom: 8px;
}
@@ -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>
)
}
@@ -2,7 +2,6 @@ import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query"
import { TextField } from "@opencode/ui/text-field"
import { showToast } from "@/shell/notifications/toast"
@@ -12,6 +11,7 @@ import { ExternalLink } from "@/runtime/platform/external-link"
import { useData } from "@/runtime/server/current"
import { useLanguage } from "@/runtime/i18n/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./form"
import { CustomManagedProviderIcon } from "@/providers/models/provider-group"
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const dialog = useDialog()
@@ -134,7 +134,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
return (
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
<div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<CustomManagedProviderIcon class="size-5 shrink-0" />
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
</div>
@@ -0,0 +1,31 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2582_70989)">
<rect x="1.33496" y="1.33496" width="13.33" height="13.33" rx="1.66625" fill="#3A3A3A"/>
<rect x="1.33496" y="1.33496" width="13.33" height="13.33" rx="1.66625" fill="url(#paint0_linear_2582_70989)" fill-opacity="0.2"/>
<circle cx="7.99988" cy="7.99988" r="4.26977" stroke="#808080" stroke-width="0.208281"/>
<circle cx="7.99993" cy="7.99993" r="2.2786" stroke="#808080" stroke-width="0.208281"/>
<rect x="1.38703" y="7.94782" width="13.2259" height="0.104141" stroke="#808080" stroke-width="0.104141"/>
<rect x="1.38703" y="12.2176" width="13.2259" height="0.104141" stroke="#808080" stroke-width="0.104141"/>
<rect x="1.38703" y="3.67805" width="13.2259" height="0.104141" stroke="#808080" stroke-width="0.104141"/>
<rect x="7.94782" y="1.38703" width="0.104141" height="13.2259" stroke="#808080" stroke-width="0.104141"/>
<rect x="3.67805" y="1.38703" width="0.104141" height="13.2259" stroke="#808080" stroke-width="0.104141"/>
<rect x="12.2176" y="1.38703" width="0.104141" height="13.2259" stroke="#808080" stroke-width="0.104141"/>
<rect x="0.930816" y="1.00439" width="0.104141" height="19.8909" transform="rotate(-45 0.930816 1.00439)" stroke="#808080" stroke-width="0.104141"/>
<rect x="14.9958" y="0.930572" width="0.104141" height="19.8909" transform="rotate(45 14.9958 0.930572)" stroke="#808080" stroke-width="0.104141"/>
<foreignObject x="0.293476" y="0.293476" width="15.4128" height="15.4128"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(1.67px);clip-path:url(#bgblur_1_2582_70989_clip_path);height:100%;width:100%"></div></foreignObject><path data-figma-bg-blur-radius="3.3325" d="M8 3.73047C10.3541 3.73055 12.2695 5.64586 12.2695 8C12.2695 10.3541 10.3541 12.2695 8 12.2695C7.25389 12.2695 6.55544 12.071 5.94434 11.7324H5.94531L7.18457 10.1377C7.43838 10.2348 7.71265 10.291 8 10.291C9.26359 10.291 10.2909 9.26329 10.291 8C10.291 7.763 10.2451 7.54049 10.1807 7.33105L10.1367 7.18945L10.0186 7.27832L8.75879 8.22363H8.22656L7.80469 7.80566C7.80396 7.79142 7.80278 7.77391 7.80176 7.75391C7.79891 7.69808 7.79481 7.6227 7.79102 7.5459C7.78495 7.42322 7.77957 7.29979 7.77734 7.24023L8.72168 5.98145L8.81055 5.86328L8.66895 5.81934C8.45944 5.7548 8.23708 5.709 8 5.70898C6.73632 5.70898 5.70898 6.73661 5.70898 8C5.709 8.2865 5.76377 8.56025 5.86035 8.81348L4.2666 10.0547H4.26562C3.92743 9.4439 3.73049 8.74556 3.73047 8C3.73047 5.64581 5.64581 3.73047 8 3.73047Z" fill="url(#paint1_linear_2582_70989)" stroke="white" stroke-width="0.208281"/>
</g>
<defs>
<clipPath id="bgblur_1_2582_70989_clip_path" transform="translate(-0.293476 -0.293476)"><path d="M8 3.73047C10.3541 3.73055 12.2695 5.64586 12.2695 8C12.2695 10.3541 10.3541 12.2695 8 12.2695C7.25389 12.2695 6.55544 12.071 5.94434 11.7324H5.94531L7.18457 10.1377C7.43838 10.2348 7.71265 10.291 8 10.291C9.26359 10.291 10.2909 9.26329 10.291 8C10.291 7.763 10.2451 7.54049 10.1807 7.33105L10.1367 7.18945L10.0186 7.27832L8.75879 8.22363H8.22656L7.80469 7.80566C7.80396 7.79142 7.80278 7.77391 7.80176 7.75391C7.79891 7.69808 7.79481 7.6227 7.79102 7.5459C7.78495 7.42322 7.77957 7.29979 7.77734 7.24023L8.72168 5.98145L8.81055 5.86328L8.66895 5.81934C8.45944 5.7548 8.23708 5.709 8 5.70898C6.73632 5.70898 5.70898 6.73661 5.70898 8C5.709 8.2865 5.76377 8.56025 5.86035 8.81348L4.2666 10.0547H4.26562C3.92743 9.4439 3.73049 8.74556 3.73047 8C3.73047 5.64581 5.64581 3.73047 8 3.73047Z"/>
</clipPath><linearGradient id="paint0_linear_2582_70989" x1="7.99996" y1="1.33496" x2="7.99996" y2="14.665" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint1_linear_2582_70989" x1="7.99988" y1="3.62598" x2="7.99988" y2="12.3737" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.8"/>
<stop offset="1" stop-color="white" stop-opacity="0.5"/>
</linearGradient>
<clipPath id="clip0_2582_70989">
<rect x="1.33496" y="1.33496" width="13.33" height="13.33" rx="1.66625" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+49 -67
View File
@@ -2,11 +2,10 @@ import { Button } from "@opencode/ui/button"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { Switch } from "@opencode/ui/switch"
import { TextInput } from "@opencode/ui/text-input"
import { useFilteredList } from "@opencode/ui/hooks"
import { For, Show, type Component } from "solid-js"
import { createMemo, For, Show, type Component } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/providers/models/selection"
import { popularProviders } from "@/providers/catalog/providers"
@@ -16,6 +15,7 @@ import { DialogConnectProvider } from "@/providers/connect/dialog"
import { decode64 } from "@/runtime/persistence/base64"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import { consoleModelGroup, ProviderModelSections } from "@/providers/models/provider-group"
import "@/settings/settings.css"
type ModelItem = ReturnType<ReturnType<typeof useLocal>["model"]["list"]>[number]
@@ -54,9 +54,35 @@ export const DialogManageModels: Component = () => {
const bPopular = bRank >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
return aRank - bRank
if (aPopular && bPopular) return aRank - bRank
return a.items[0].provider.name.localeCompare(b.items[0].provider.name)
},
})
const managed = createMemo(() => consoleModelGroup(local.model.list()))
const searching = () => list.filter().length > 0
const expanded = (key: string) => searching() || !store.collapsed[key]
function ModelRows(props: { items: ModelItem[] }) {
return (
<SettingsList variant="catalog">
<For each={props.items}>
{(item) => (
<SettingsRow title={item.name} description="">
<div>
<Switch
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
onChange={(checked) => setModelVisibility(item, checked)}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRow>
)}
</For>
</SettingsList>
)
}
return (
<Dialog size="large" variant="settings" class="settings-manage-models-dialog">
@@ -100,7 +126,7 @@ export const DialogManageModels: Component = () => {
</div>
</div>
<div data-slot="manage-models-scroll" class="relative min-h-0 flex-1">
<div class="settings-panel settings-models h-full px-4 pt-4 pb-4">
<div class="settings-panel settings-models h-full px-4 pt-1 pb-4">
<Show
when={!list.grouped.loading}
fallback={
@@ -121,69 +147,25 @@ export const DialogManageModels: Component = () => {
</div>
}
>
<For each={list.grouped.latest}>
{(group) => {
const searching = () => list.filter().length > 0
const expanded = () => searching() || !store.collapsed[group.category]
return (
<div
class="settings-section"
data-component="settings-models-provider"
data-expanded={expanded() ? "" : undefined}
>
<div class="settings-models-group-header justify-between">
<button
type="button"
class="settings-models-group-trigger"
aria-expanded={expanded()}
disabled={searching()}
onClick={() => setStore("collapsed", group.category, expanded())}
>
<span class="settings-models-group-chevron">
<Icon
name="chevron-down"
size="small"
classList={{ "-rotate-90 rtl:rotate-90": !expanded() }}
/>
</span>
<span class="settings-models-group-label">
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
</span>
</button>
<Switch
class="me-6"
checked={providerVisible(group.category)}
onChange={(checked) => setProviderVisibility(group.category, checked)}
hideLabel
>
{group.items[0].provider.name}
</Switch>
</div>
<Show when={expanded()}>
<SettingsList variant="catalog">
<For each={group.items}>
{(item) => (
<SettingsRow title={item.name} description="">
<div>
<Switch
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
onChange={(checked) => setModelVisibility(item, checked)}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRow>
)}
</For>
</SettingsList>
</Show>
</div>
)
}}
</For>
<ProviderModelSections
groups={list.grouped.latest}
managed={managed()}
expanded={expanded}
disabled={searching()}
onExpandedChange={(key, value) => setStore("collapsed", key, !value)}
onSetVisibility={setProviderVisibility}
action={(group) => (
<Switch
class="me-6"
checked={providerVisible(group.category)}
onChange={(checked) => setProviderVisibility(group.category, checked)}
hideLabel
>
{group.items[0].provider.name}
</Switch>
)}
rows={(items) => <ModelRows items={items} />}
/>
</Show>
</Show>
</div>
@@ -0,0 +1,248 @@
import { Badge } from "@opencode/ui/badge"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { iconNames, type IconName } from "@opencode/ui/icons/provider"
import { Menu } from "@opencode/ui/menu"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import type { JSX } from "solid-js"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { consoleProviderGroup, consoleProviderName } from "@/providers/catalog/console"
import { OpenCodeLogo } from "@/providers/opencode-logo"
import { useLanguage } from "@/runtime/i18n/language"
import customManagedProvider from "@/providers/custom-managed-provider.svg"
import "@/settings/settings.css"
type ModelProvider = { id: string; canonical?: string; name: string }
type ModelItem = { provider: ModelProvider & { integrationID?: string }; cost?: { input: number } }
type ModelGroup<T> = { category: string; items: T[] }
export const CONSOLE_GROUP_KEY = "console:opencode"
export function CustomManagedProviderIcon(props: { class?: string }) {
return <img data-component="custom-managed-provider-icon" src={customManagedProvider} alt="" class={props.class} />
}
export function ProviderModelIcon(props: { provider: ModelProvider; class?: string }) {
const icon = () =>
[
props.provider.canonical,
props.provider.canonical?.replace(/-token-plan$/, ""),
props.provider.id.replace(/^console-/, ""),
].find((id): id is IconName => !!id && id !== "synthetic" && iconNames.includes(id as IconName))
return (
<Switch>
<Match when={props.provider.id === "opencode"}>
<OpenCodeLogo class={`size-4 ${props.class ?? ""}`} />
</Match>
<Match when={icon()} keyed>
{(id) => <ProviderIcon id={id} width={16} height={16} class={props.class} />}
</Match>
<Match when={true}>
<CustomManagedProviderIcon class={`size-4 ${props.class ?? ""}`} />
</Match>
</Switch>
)
}
/** Detects the Console workspace from every listed model, so a search that hides some providers keeps the group. */
export function consoleModelGroup<T extends ModelItem>(items: readonly T[]) {
return consoleProviderGroup([...new Map(items.map((item) => [item.provider.id, item.provider])).values()])
}
/** Provider sections for a model list, with Console workspace providers nested under one OpenCode Console section. */
export function ProviderModelSections<T extends ModelItem>(props: {
groups: ModelGroup<T>[]
managed: ReturnType<typeof consoleModelGroup<T>>
expanded: (key: string) => boolean
disabled: boolean
onExpandedChange: (key: string, expanded: boolean) => void
rows: (items: T[]) => JSX.Element
/** Trailing control in a direct provider's header. */
action?: (group: ModelGroup<T>) => JSX.Element
onSetVisibility?: (providerID: string, visible: boolean) => void
ref?: (providerID: string, element: HTMLElement) => void
}) {
type Section = {
group?: ModelGroup<T>
managed?: { group: NonNullable<ReturnType<typeof consoleModelGroup<T>>>; providers: ModelGroup<T>[] }
}
const language = useLanguage()
const sections = createMemo<Section[]>(() => {
const managed = props.managed
const ids = new Set(managed?.providers.map((provider) => provider.id))
const nested = props.groups.filter((group) => ids.has(group.category))
if (!managed || nested.length === 0) return props.groups.map((group) => ({ group }))
const first = props.groups.findIndex((group) => ids.has(group.category))
return props.groups.flatMap<Section>((group, index) => {
if (!ids.has(group.category)) return [{ group }]
if (index !== first) return []
return [{ managed: { group: managed, providers: nested } }]
})
})
// Only the keyless catalog is free; a Zen key or Console account keeps the provider's own name.
const name = (group: ModelGroup<T>) =>
group.category === "opencode" && group.items.every((item) => !item.cost?.input)
? language.t("provider.connect.opencode.freeName")
: group.items[0].provider.name
function Header(input: { id: string; icon: JSX.Element; title: JSX.Element; badge?: string; action?: JSX.Element }) {
return (
<h3 class="settings-models-group-header" classList={{ "justify-between": !!input.action }}>
<button
type="button"
class="settings-models-group-trigger"
aria-expanded={props.expanded(input.id)}
disabled={props.disabled}
onClick={() => props.onExpandedChange(input.id, !props.expanded(input.id))}
>
<span class="settings-models-group-chevron">
<Icon name="chevron-down" size="small" classList={{ collapsed: !props.expanded(input.id) }} />
</span>
<span class="settings-models-group-label">
{input.icon}
<bdi class="settings-models-group-title">{input.title}</bdi>
<Show when={input.badge}>{(badge) => <Badge>{badge()}</Badge>}</Show>
</span>
</button>
{input.action}
</h3>
)
}
return (
<For each={sections()}>
{(section) => (
<Show
when={section.managed}
fallback={
<Show when={section.group}>
{(group) => (
<section
ref={(element) => props.ref?.(group().category, element)}
class="settings-section"
data-component="settings-models-provider"
data-expanded={props.expanded(group().category) ? "" : undefined}
>
<Header
id={group().category}
icon={<ProviderModelIcon provider={group().items[0].provider} class="shrink-0" />}
title={name(group())}
action={props.action?.(group())}
/>
<Show when={props.expanded(group().category)}>{props.rows(group().items)}</Show>
</section>
)}
</Show>
}
>
{(managed) => (
<section
class="settings-section"
data-component="settings-models-console"
data-expanded={props.expanded(CONSOLE_GROUP_KEY) ? "" : undefined}
>
<Header
id={CONSOLE_GROUP_KEY}
icon={<OpenCodeLogo class="size-4 shrink-0" />}
title={language.t("provider.connect.opencode.name")}
badge={managed().group.workspace}
/>
<Show when={props.expanded(CONSOLE_GROUP_KEY)}>
<div class="provider-model-groups settings-models-console-groups">
<For each={managed().providers}>
{(group) => (
<ProviderModelGroup
ref={(element) => props.ref?.(group.category, element)}
provider={group.items[0].provider}
name={consoleProviderName(managed().group, group.items[0].provider.name)}
expanded={props.expanded(group.category)}
disabled={props.disabled}
onSetVisibility={
props.onSetVisibility
? (visible) => props.onSetVisibility?.(group.category, visible)
: undefined
}
onExpandedChange={(value) => props.onExpandedChange(group.category, value)}
>
{props.rows(group.items)}
</ProviderModelGroup>
)}
</For>
</div>
</Show>
</section>
)}
</Show>
)}
</For>
)
}
export function ProviderModelGroup(props: {
provider: ModelProvider
name?: string
expanded: boolean
disabled?: boolean
onSetVisibility?: (visible: boolean) => void
children: JSX.Element
ref?: (element: HTMLElement) => void
onExpandedChange: (expanded: boolean) => void
}) {
const language = useLanguage()
return (
<section
ref={props.ref}
class="provider-model-group"
data-component="provider-model-group"
data-provider={props.provider.id}
data-expanded={props.expanded ? "" : undefined}
>
<h3 class="provider-model-group-header">
<button
type="button"
class="provider-model-group-trigger"
aria-expanded={props.expanded}
disabled={props.disabled}
onClick={() => props.onExpandedChange(!props.expanded)}
>
<span class="provider-model-group-label">
<ProviderModelIcon provider={props.provider} class="shrink-0" />
<bdi class="provider-model-group-title">{props.name ?? props.provider.name}</bdi>
<Icon
name="chevron-down"
size="small"
classList={{ "provider-model-group-chevron": true, collapsed: !props.expanded }}
/>
</span>
</button>
<Show when={props.onSetVisibility} keyed>
{(setVisibility) => (
<div class="provider-model-group-actions">
<Menu gutter={4} modal={false} placement="bottom-end">
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
size="small"
icon={<Icon name="outline-dots" />}
aria-label={language.t("common.moreOptions")}
/>
<Menu.Portal>
<Menu.Content>
<Menu.Item onSelect={() => setVisibility(true)}>{language.t("settings.models.enableAll")}</Menu.Item>
<Menu.Item onSelect={() => setVisibility(false)}>
{language.t("settings.models.disableAll")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
)}
</Show>
</h3>
<Show when={props.expanded}>
<div class="provider-model-group-models">{props.children}</div>
</Show>
</section>
)
}
@@ -6,14 +6,13 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { popularProviders } from "@/providers/catalog/providers"
import { Button } from "@opencode/ui/button"
import { Badge } from "@opencode/ui/badge"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode/ui/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { ScrollView } from "@opencode/ui/scroll-view"
import { Tooltip } from "@opencode/ui/tooltip"
import { Menu } from "@opencode/ui/menu"
import { TextInput } from "@opencode/ui/text-input"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { ModelTooltip } from "./tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import { decode64 } from "@/runtime/persistence/base64"
@@ -22,6 +21,7 @@ import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./search"
import { SettingsList } from "@/settings/list"
import { CONSOLE_GROUP_KEY, consoleModelGroup, ProviderModelSections } from "@/providers/models/provider-group"
import "@/settings/settings.css"
const isFree = (provider: string, cost: { input: number } | undefined) =>
@@ -62,9 +62,14 @@ const ModelList: Component<{
collapsed: {} as Record<string, boolean>,
})
const models = createMemo(() => controller.models(store.search))
const groups = createMemo(() => controller.groups(models()))
const modelGroups = createMemo(() => controller.groups(models()))
const managed = createMemo(() => consoleModelGroup(controller.all()))
const expanded = (provider: string) => store.search.length > 0 || !store.collapsed[provider]
const visibleModels = () => models().filter((item) => expanded(item.provider.id))
const managedIDs = createMemo(() => new Set(managed()?.providers.map((provider) => provider.id) ?? []))
const visibleModels = () =>
models().filter(
(item) => expanded(item.provider.id) && (!managedIDs().has(item.provider.id) || expanded(CONSOLE_GROUP_KEY)),
)
let scrollRef: HTMLDivElement | undefined
const setSearch = (value: string) => {
@@ -88,6 +93,53 @@ const ModelList: Component<{
if (item) controller.select(item)
}
function ModelRows(props: { items: ModelItem[] }) {
return (
<SettingsList variant="catalog">
<For each={props.items}>
{(item) => (
<button
type="button"
data-component="settings-row"
data-option-key={modelKey(item)}
class="-mx-4 w-[calc(100%+32px)] px-4 text-start first:rounded-t-lg last:rounded-b-lg hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => setStore("active", modelKey(item))}
onMouseLeave={() => setStore("active", "")}
onClick={() => controller.select(item)}
>
<div data-slot="settings-row-copy">
<div data-slot="settings-row-title" class="flex items-center gap-2">
<Tooltip
placement="right-start"
gutter={12}
openDelay={0}
value={
<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} v2 />
}
>
<span class="min-w-0 truncate">{item.name}</span>
</Tooltip>
<Show when={isFree(item.provider.id, item.cost)}>
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
</Show>
<Show when={item.latest}>
<Badge class="shrink-0">{language.t("model.tag.latest")}</Badge>
</Show>
</div>
</div>
<div data-slot="settings-row-control" class="size-4">
<Show when={controller.current() === modelKey(item)}>
<Icon name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
</Show>
</div>
</button>
)}
</For>
</SettingsList>
)
}
return (
<div class="flex min-h-0 flex-1 flex-col">
<div class="shrink-0 px-4 pt-px pb-3">
@@ -137,88 +189,19 @@ const ModelList: Component<{
</div>
</div>
<div class="relative min-h-0 flex-1">
<div ref={(element) => (scrollRef = element)} class="settings-panel settings-models h-full px-4 pt-4 pb-4">
<div ref={(element) => (scrollRef = element)} class="settings-panel settings-models h-full px-4 pt-1 pb-4">
<Show
when={models().length > 0}
fallback={<div class="settings-models-status">{language.t("dialog.model.empty")}</div>}
>
<For each={groups()}>
{(group) => {
const searching = () => store.search.length > 0
const open = () => expanded(group.category)
return (
<section class="settings-section" data-expanded={open() ? "" : undefined}>
<h3 class="settings-models-group-header">
<button
type="button"
class="settings-models-group-trigger"
aria-expanded={open()}
disabled={searching()}
onClick={() => setStore("collapsed", group.category, open())}
>
<span class="settings-models-group-chevron">
<Icon name="chevron-down" size="small" classList={{ "-rotate-90 rtl:rotate-90": !open() }} />
</span>
<span class="settings-models-group-label">
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
</span>
</button>
</h3>
<Show when={open()}>
<SettingsList variant="catalog">
<For each={group.items}>
{(item) => (
<button
type="button"
data-component="settings-row"
data-option-key={modelKey(item)}
class="-mx-4 w-[calc(100%+32px)] px-4 text-start first:rounded-t-lg last:rounded-b-lg hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => setStore("active", modelKey(item))}
onMouseLeave={() => setStore("active", "")}
onClick={() => controller.select(item)}
>
<div data-slot="settings-row-copy">
<div data-slot="settings-row-title" class="flex items-center gap-2">
<Tooltip
placement="right-start"
gutter={12}
openDelay={0}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={isFree(item.provider.id, item.cost)}
v2
/>
}
>
<span class="min-w-0 truncate">{item.name}</span>
</Tooltip>
<Show when={isFree(item.provider.id, item.cost)}>
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
</Show>
<Show when={item.latest}>
<Badge class="shrink-0">{language.t("model.tag.latest")}</Badge>
</Show>
</div>
</div>
<div data-slot="settings-row-control" class="size-4">
<Show when={controller.current() === modelKey(item)}>
<Icon name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
</Show>
</div>
</button>
)}
</For>
</SettingsList>
</Show>
</section>
)
}}
</For>
<ProviderModelSections
groups={modelGroups()}
managed={managed()}
expanded={expanded}
disabled={store.search.length > 0}
onExpandedChange={(key, value) => setStore("collapsed", key, !value)}
rows={(items) => <ModelRows items={items} />}
/>
</Show>
</div>
</div>
@@ -272,6 +255,7 @@ function createModelSelectorController(input: {
)
return {
all: () => model.list().filter((item) => (input.provider() ? item.provider.id === input.provider() : true)),
models: (search: string) => {
const query = search.trim()
const filtered = query
@@ -548,7 +532,7 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
return (
<Dialog size="large" variant="settings">
<DialogHeader hideClose closeLabel={language.t("common.close")}>
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
<DialogTitleGroup title={language.t("dialog.model.select.title")} />
<Button icon="plus" onClick={provider}>
{language.t("command.provider.connect")}
</Button>
+8 -3
View File
@@ -1,7 +1,7 @@
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog"
import { Badge } from "@opencode/ui/badge"
import { Icon } from "@opencode/ui/icon"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { ProviderModelIcon } from "@/providers/models/provider-group"
import { Tooltip } from "@opencode/ui/tooltip"
import { useDialog } from "@opencode/ui/context/dialog"
import { useTheme } from "@opencode/ui/theme"
@@ -31,6 +31,8 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
})
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
const providerName = (provider: { id: string; name: string }) =>
provider.id === "opencode" ? language.t("provider.connect.opencode.name") : provider.name
const freeModels = createMemo(() => model.list().filter(isFree))
const openProviders = (provider?: string) => {
@@ -144,9 +146,12 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
}}
onClick={() => openProviders(provider.id)}
>
<ProviderIcon id={provider.id} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
<ProviderModelIcon
provider={{ id: provider.id, name: providerName(provider) }}
class="mt-0.5 shrink-0 text-v2-icon-icon-base"
/>
<span class="flex min-w-0 flex-col">
<span class="truncate">{provider.name}</span>
<span class="truncate">{providerName(provider)}</span>
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
<span class="truncate font-[440] text-v2-text-text-muted">
{language.t(
@@ -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>
)
}
+41 -5
View File
@@ -195,6 +195,43 @@ export const dict = {
"dialog.provider.viewAll": "Show more providers",
"provider.connect.title": "Connect {{provider}}",
"provider.connect.opencode.name": "OpenCode Console",
"provider.connect.opencode.freeName": "OpenCode Free",
"provider.connect.console.title": "Connecting to 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.deviceCode.label": "Device code: {{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.available": "Available models",
"provider.connect.models.list": "Models available from {{provider}}",
"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.noModels":
"Your account is connected, but this Console workspace has no available models. Check its setup in Console, then refresh.",
"provider.connect.console.modelsLoading": "Your models are still loading. Refresh to check again.",
"provider.connect.console.refresh": "Refresh models",
"provider.connect.console.openingBrowser": "Opening browser…",
"provider.connect.console.serviceAccount": "Service account?",
"provider.connect.console.useApiKey": "Use API key",
"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.connect.title.anthropicProMax": "Login with Anthropic",
"provider.connect.selectMethod": "Select login method for {{provider}}.",
"provider.connect.method.apiKey": "API key",
@@ -236,11 +273,6 @@ export const dict = {
"Your browser opens so you can sign in to {{provider}}. Paste the authorization code it gives you below.",
"provider.connect.oauth.openBrowser": "Open browser",
"provider.connect.oauth.expired": "Authorization expired",
"provider.connect.console.description":
"Sign in with your OpenCode Console account. Your browser opens to the Console, where you pick a workspace and select Authorize.",
"provider.connect.console.opening": "Opening your browser…",
"provider.connect.console.code.placeholder": "Requesting a code…",
"provider.connect.console.apiKey.switch": "Connect with an API key or service account instead",
"provider.connect.console.apiKey.description":
"Paste an API key for a service account. You create service accounts in the OpenCode Console under Keys.",
"provider.connect.console.apiKey.link": "Open the Console",
@@ -1382,6 +1414,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",
@@ -1391,6 +1425,8 @@ export const dict = {
"settings.providers.tag.other": "Other",
"settings.models.title": "Models",
"settings.models.description": "Choose which models appear in model picker",
"settings.models.enableAll": "Enable all models",
"settings.models.disableAll": "Disable all models",
"settings.agents.title": "Agents",
"settings.agents.description": "Agent settings will be configurable here.",
"settings.commands.title": "Commands",
@@ -43,6 +43,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>
@@ -51,6 +51,7 @@ export function normalizeProviderList(
for (const provider of providers) {
all.set(provider.id, {
id: provider.id,
canonical: provider.canonical,
integrationID: provider.integrationID,
name: provider.name,
source: "custom",
+1
View File
@@ -129,6 +129,7 @@ export type Model = {
export type Provider = {
id: string
canonical?: string
/** Integration that connects this provider; differs from `id` for Console-managed providers. */
integrationID?: string
name: string
+82 -83
View File
@@ -1,10 +1,9 @@
import { useFilteredList } from "@opencode/ui/hooks"
import { ProviderIcon } from "@opencode/ui/provider-icon"
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, createEffect, For, on, onCleanup, Show } from "solid-js"
import { type Component, createEffect, createMemo, For, on, onCleanup, Show } from "solid-js"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { useLanguage } from "@/runtime/i18n/language"
@@ -14,17 +13,21 @@ import { popularProviders } from "@/providers/catalog/providers"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import { CONSOLE_GROUP_KEY, consoleModelGroup, ProviderModelSections } from "@/providers/models/provider-group"
import "@/settings/settings.css"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
const PROVIDER_ICON_SIZE = 16
export const ModelProvidersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
})
export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }> = (props) => {
export const SettingsModels: Component<{
active?: boolean
autofocus?: boolean
provider?: string
onReveal?: () => void
}> = (props) => {
const language = useLanguage()
const models = useModels()
const serverSdk = useServerSDK()
@@ -47,6 +50,7 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }
ModelProvidersSchema,
{ collapsed: {} },
)
const sections = new Map<string, HTMLElement>()
const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(),
@@ -69,6 +73,69 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }
return aName.localeCompare(bName)
},
})
const managed = createMemo(() => consoleModelGroup(models.list()))
const searching = () => list.filter().length > 0
const expanded = (key: string) => searching() || !store.collapsed[key]
const setProviderVisibility = (providerID: string, visible: boolean) =>
models
.list()
.filter((item) => item.provider.id === providerID)
.forEach((item) => models.setVisibility({ providerID, modelID: item.id }, visible))
function ModelRows(props: { items: ModelItem[] }) {
return (
<SettingsList variant="catalog">
<For each={props.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
return (
<SettingsRow title={item.name} description="">
<div>
<Switch
checked={models.visible(key)}
onChange={(checked) => models.setVisibility(key, checked)}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRow>
)
}}
</For>
</SettingsList>
)
}
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
// Expand only the path to the target so the saved layout of other providers is kept.
if (managed()?.providers.some((item) => item.id === provider)) setStore("collapsed", CONSOLE_GROUP_KEY, false)
setStore("collapsed", provider, false)
requestAnimationFrame(() => {
const panel = section.closest<HTMLElement>(".settings-panel")
const header = panel?.querySelector<HTMLElement>(".settings-tab-header")
if (panel && header) {
panel.scrollTo({
top: panel.scrollTop + section.getBoundingClientRect().top - header.getBoundingClientRect().bottom - 24,
})
} else {
section.scrollIntoView({ block: "start" })
}
section
.querySelector<HTMLElement>(".provider-model-group-trigger, .settings-models-group-trigger")
?.focus({ preventScroll: true })
props.onReveal?.()
})
})
return (
<>
@@ -127,84 +194,16 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }
</div>
}
>
<For each={list.grouped.latest}>
{(group) => {
const searching = () => list.filter().length > 0
const expanded = () => searching() || !store.collapsed[group.category]
return (
<div
class="settings-section"
data-component="settings-models-provider"
data-expanded={expanded() ? "" : undefined}
>
<h3 class="settings-models-group-header">
<button
type="button"
class="settings-models-group-trigger"
aria-expanded={expanded()}
disabled={searching()}
onClick={() => setStore("collapsed", group.category, expanded())}
>
<span class="settings-models-group-chevron">
<Show
when={expanded()}
fallback={
<svg width="5" height="6" viewBox="0 0 5 6" fill="none" aria-hidden="true">
<path
d="M0.75194 5.31663C0.41861 5.51103 0 5.27063 0 4.88473V0.500754C0 0.114854 0.41861 -0.125577 0.75194 0.0688635L4.5096 2.26084C4.8404 2.45378 4.8404 2.93168 4.5096 3.12462L0.75194 5.31663Z"
fill="currentColor"
/>
</svg>
}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M5.37624 6.75194C5.18184 6.41861 5.42224 6 5.80814 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43203 10.5096C8.23909 10.8404 7.76119 10.8404 7.56825 10.5096L5.37624 6.75194Z"
fill="currentColor"
/>
</svg>
</Show>
</span>
<span class="settings-models-group-label">
<ProviderIcon
id={group.category}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-models-provider-icon shrink-0"
/>
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
</span>
</button>
</h3>
<Show when={expanded()}>
<SettingsList variant="catalog">
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
return (
<SettingsRow title={item.name} description="">
<div>
<Switch
checked={models.visible(key)}
onChange={(checked) => {
models.setVisibility(key, checked)
}}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRow>
)
}}
</For>
</SettingsList>
</Show>
</div>
)
}}
</For>
<ProviderModelSections
groups={list.grouped.latest}
managed={managed()}
expanded={expanded}
disabled={searching()}
onExpandedChange={(key, value) => setStore("collapsed", key, !value)}
onSetVisibility={setProviderVisibility}
ref={(providerID, element) => sections.set(providerID, element)}
rows={(items) => <ModelRows items={items} />}
/>
</Show>
</Show>
</div>
+213 -49
View File
@@ -1,15 +1,20 @@
import { Button } from "@opencode/ui/button"
import { Badge } from "@opencode/ui/badge"
import { useDialog } from "@opencode/ui/context/dialog"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { Icon } from "@opencode/ui/icon"
import { OpenCodeLogo } from "@/providers/opencode-logo"
import { showToast } from "@/shell/notifications/toast"
import { popularProviders, useProviders } from "@/providers/catalog/providers"
import { consoleProviderGroup } from "@/providers/catalog/console"
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, reconcile } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { CONSOLE_INTEGRATION } from "@/providers/connect/controller"
import { useData } from "@/runtime/server/current"
import { CONSOLE_INTEGRATION, CONSOLE_PROVIDERS } from "@/providers/connect/controller"
import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog"
import { ProviderModelIcon } from "@/providers/models/provider-group"
import { SettingsList } from "@/settings/list"
import "@/settings/settings.css"
@@ -27,18 +32,27 @@ const PROVIDER_NOTES = [
{ match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
] as const
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()
const serverSdk = useServerSDK()
const data = useData()
const providers = useProviders(() => props.directory)
const integrations = useIntegrations(() => props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack })
const providerConnect = useProviderConnectController()
const [state, setState] = createStore({
disconnecting: {} as Record<string, "removing" | "removed" | "absent" | undefined>,
consoleExpanded: false,
connecting: false,
})
const updateDisconnecting = (ids: string[], status: "removing" | "removed" | "absent" | undefined) =>
setState("disconnecting", (current) => ({
...current,
...Object.fromEntries(ids.map((id) => [id, status])),
}))
// Console-managed providers (`opencode-go`, `console-*`) connect through the `opencode`
// integration, so the lookup must follow `integrationID` rather than the provider id.
const integration = (item: ProviderItem) => {
@@ -47,32 +61,97 @@ export const SettingsProviders: Component<{
}
const connect = (provider?: string) => {
setState("connecting", true)
providerConnect.select(provider)
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
void dialog.show(
() => (
<DialogConnectProvider
directory={props.directory}
defaultLocation={props.directory === undefined}
controller={providerConnect}
onConnected={(providerID) => {
if (CONSOLE_PROVIDERS.has(providerID)) {
setState("disconnecting", reconcile({}))
return
}
setState("disconnecting", providerID, undefined)
}}
/>
),
() => {
setState("connecting", false)
const location = props.directory ? { directory: props.directory } : undefined
data.location.integration.invalidate(location)
data.location.provider.invalidate(location)
data.location.model.invalidate(location)
void Promise.all([
data.location.integration.sync(location),
data.location.provider.sync(location),
data.location.model.sync(location),
]).catch(() => undefined)
},
)
}
const connected = createMemo(() => {
return providers
.connected()
const available = createMemo(() => {
const connected = providers.connected()
const managedConsole = consoleProviderGroup(connected)
const consoleConnected = integrations
.list()
.find((item) => item.id === CONSOLE_INTEGRATION)
?.connections.some((connection) => connection.type === "credential" || connection.type === "env")
// Hides the free `opencode` row while a new Console grant is still loading its workspace providers.
const consoleTransition =
state.connecting &&
CONSOLE_PROVIDERS.has(providerConnect.selected() ?? "") &&
consoleConnected &&
managedConsole === undefined
return connected
.filter(
(provider) =>
provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost.input > 0),
provider.id !== "opencode" ||
(!consoleTransition &&
(managedConsole !== undefined ||
consoleConnected ||
Object.values(provider.models).some((model) => model.cost.input > 0))),
)
.toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go"))
})
createEffect(() => {
const ids = new Set(available().map((item) => item.id))
Object.entries(state.disconnecting).forEach(([id, status]) => {
if ((status === "removing" || status === "removed") && !ids.has(id)) {
setState("disconnecting", id, "absent")
return
}
if (status === "absent" && ids.has(id)) setState("disconnecting", id, undefined)
})
})
const connected = createMemo(() => available().filter((item) => !state.disconnecting[item.id]))
const consoleGroup = createMemo(() => consoleProviderGroup(available()))
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))
// The Console account (integration `opencode`) shares its id with the Zen provider. A stored API
// key, including one imported from a v1 auth.json, makes Zen "connected" without any account, so
// the Popular list keeps the sign-in row until the active credential is an OAuth grant. Until the
// integration list arrives the row is still the models.dev Zen provider, so dedupe it as before.
const console = integrations.list().find((entry) => entry.id === CONSOLE_INTEGRATION)
const account = integrations.list().find((entry) => entry.id === CONSOLE_INTEGRATION)
const items = providers
.popular()
.filter((p) => {
if (p.id !== CONSOLE_INTEGRATION || !console) return !connectedIDs.has(p.id)
return console.connections.find((connection) => connection.type === "credential")?.method !== "oauth"
if (p.id !== CONSOLE_INTEGRATION || !account) return !connectedIDs.has(p.id)
return account.connections.find((connection) => connection.type === "credential")?.method !== "oauth"
})
.slice()
items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id))
@@ -113,8 +192,11 @@ export const SettingsProviders: Component<{
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const disconnect = async (item: ProviderItem) => {
const name = item.name
const disconnect = async (item: ProviderItem, name: string) => {
if (state.disconnecting[item.id]) return
const group = consoleGroup()
const ids = group?.root.id === item.id ? group.providers.map((provider) => provider.id) : [item.id]
updateDisconnecting(ids, "removing")
const location = props.directory ? { directory: props.directory } : undefined
await serverSdk.api.integration
.get({ integrationID: item.integrationID ?? item.id, location })
@@ -124,6 +206,7 @@ export const SettingsProviders: Component<{
await Promise.all(
credentials.map((credential) => serverSdk.api.credential.remove({ credentialID: credential.id })),
)
updateDisconnecting(ids, "removed")
showToast({
variant: "success",
icon: "circle-check",
@@ -132,6 +215,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 })
})
@@ -153,38 +237,120 @@ export const SettingsProviders: Component<{
<h3 class="settings-section-title">{language.t("settings.providers.section.connected")}</h3>
<SettingsList variant="catalog">
<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 managedGroup = () => (consoleGroup()?.root.id === item.id ? consoleGroup() : undefined)
return (
<Show
when={canDisconnect(item)}
when={managedGroup()}
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">
<ProviderModelIcon provider={item} 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>
<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, item.name)
}
>
{language.t("common.disconnect")}
</Button>
</Show>
</div>
}
>
<Button size="normal" variant="ghost-muted" onClick={() => void disconnect(item)}>
{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.opencode.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, language.t("provider.connect.opencode.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>
@@ -197,15 +363,13 @@ 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"
/>
<ProviderModelIcon provider={item} class="settings-provider-icon shrink-0" />
<div class="settings-provider-copy">
<div class="settings-provider-main">
<span class="settings-provider-name">{item.name}</span>
<span class="settings-provider-name">
{item.name}
</span>
<Show when={item.id === "opencode" || item.id === "opencode-go"}>
<Badge>{language.t("dialog.provider.tag.recommended")}</Badge>
</Show>
+287
View File
@@ -677,6 +677,134 @@
border-bottom: none;
}
[data-component="connected-providers-section"] .settings-provider-row {
min-height: 48px;
padding-block: 10px;
}
.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;
@@ -938,6 +1066,18 @@
color: var(--v2-icon-icon-muted);
}
.settings-models-group-chevron svg {
transition: transform 150ms ease;
}
.settings-models-group-chevron svg.collapsed {
transform: rotate(-90deg);
}
:dir(rtl) .settings-models-group-chevron svg.collapsed {
transform: rotate(90deg);
}
.settings-models-group-label {
display: flex;
align-items: center;
@@ -950,6 +1090,153 @@
line-height: var(--line-height-compact);
}
.provider-model-groups {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
}
.settings-models-console-groups {
margin-inline-start: 14px;
padding-inline-start: 16px;
border-inline-start: 0.5px solid var(--v2-border-border-base);
}
.provider-model-group {
min-width: 0;
overflow: hidden;
border-radius: 8px;
background-color: var(--v2-background-bg-layer-01);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
}
.provider-model-group-header {
display: flex;
min-width: 0;
align-items: center;
}
.provider-model-group-trigger {
display: flex;
min-width: 0;
flex: 1;
min-height: 48px;
align-items: center;
justify-content: flex-start;
gap: 16px;
padding: 12px 16px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--v2-text-text-base);
text-align: start;
}
.provider-model-group-trigger:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: -2px;
}
@media (hover: hover) {
.provider-model-group-trigger:not(:disabled):hover {
background-color: var(--v2-background-bg-layer-02);
}
}
.provider-model-group-trigger:disabled {
cursor: default;
}
.provider-model-group-label {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.provider-model-group-title {
min-width: 0;
overflow: hidden;
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-model-group-chevron {
flex-shrink: 0;
margin-inline-start: -2px;
color: var(--v2-icon-icon-muted);
transition: transform 150ms ease;
}
.provider-model-group-chevron.collapsed {
transform: rotate(-90deg);
}
:dir(rtl) .provider-model-group-chevron.collapsed {
transform: rotate(90deg);
}
.provider-model-group-actions {
position: relative;
z-index: 1;
display: flex;
flex-shrink: 0;
align-items: center;
gap: 8px;
padding-inline-end: 12px;
}
.provider-model-group-models {
padding-inline: 16px;
}
.provider-model-group-models > [data-component="settings-list"] {
padding-inline: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
border-top: 0.5px solid var(--v2-border-border-base);
}
.provider-model-group:has(.provider-model-group-trigger:focus-visible)
.provider-model-group-models
> [data-component="settings-list"] {
border-top-color: transparent;
}
.provider-model-group:has(.provider-model-group-trigger:focus-visible) {
outline: 0.5px solid var(--v2-border-border-muted);
outline-offset: -0.5px;
}
@media (hover: hover) {
.provider-model-group:has(.provider-model-group-trigger:hover) {
outline: 0.5px solid var(--v2-border-border-muted);
outline-offset: -0.5px;
}
.provider-model-group:has(.provider-model-group-trigger:hover)
.provider-model-group-models
> [data-component="settings-list"] {
border-top-color: transparent;
}
}
.provider-model-groups--dialog {
padding-block: 2px;
}
@media (prefers-reduced-motion: reduce) {
.settings-models-group-chevron svg,
.provider-model-group-chevron {
transition: none;
}
}
.settings-models [data-component="provider-icon"] {
color: var(--v2-icon-icon-base);
}
+33 -6
View File
@@ -191,7 +191,10 @@ function RootSettings() {
const servers = useServerCollectionController()
const inventory = useSettingsServers()
const platform = usePlatform()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
const [state, setState] = createStore({
worktreeFilterReset: 0,
modelProvider: undefined as string | undefined,
})
const list = servers.collection.items
const singleEntry = createMemo(() => (inventory().length === 1 ? inventory()[0] : undefined))
const single = createMemo(() => singleEntry()?.connection)
@@ -320,10 +323,21 @@ function RootSettings() {
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={undefined} onBack={() => surface.select("providers")} />
<SettingsProviders
directory={undefined}
onSelectProvider={(providerID) => {
setState("modelProvider", providerID)
surface.select("models")
}}
/>
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels active={surface.view().tab === "models"} autofocus={!surface.search.state.selected} />
<SettingsModels
active={surface.view().tab === "models"}
autofocus={!surface.search.state.selected}
provider={state.modelProvider}
onReveal={() => setState("modelProvider", undefined)}
/>
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions subtab={surface.view().subtab} onSubtab={(value) => surface.subtab(value)} />
@@ -347,7 +361,10 @@ function ServerSettings(props: { entry: SettingsServer }) {
const surface = useSettingsSurface()
const activeDirectory = useSettingsDirectory(() => props.entry.connection)
const prefetchWorkspaces = useWorkspacesPrefetch(() => props.entry.connection)
const [state, setState] = createStore({ worktreeFilterReset: 0 })
const [state, setState] = createStore({
worktreeFilterReset: 0,
modelProvider: undefined as string | undefined,
})
const groups = createMemo<SettingsNavGroup[]>(() => [
{
items: nestedServerTabs.map((item) => ({
@@ -402,10 +419,20 @@ function ServerSettings(props: { entry: SettingsServer }) {
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={undefined} onBack={() => surface.select("providers")} />
<SettingsProviders
directory={undefined}
onSelectProvider={(providerID) => {
setState("modelProvider", providerID)
surface.select("models")
}}
/>
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels active={surface.view().tab === "models"} />
<SettingsModels
active={surface.view().tab === "models"}
provider={state.modelProvider}
onReveal={() => setState("modelProvider", undefined)}
/>
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions subtab={surface.view().subtab} onSubtab={(value) => surface.subtab(value)} />
+7 -2
View File
@@ -121,9 +121,14 @@ export const openExternalURL = Effect.fn("DesktopFiles.openExternalURL")(functio
const url = resolveExternalURL(value)
if (!url) {
yield* scoped("window", Effect.logWarning("blocked external target", { url: value }))
return
return false
}
yield* Effect.promise(() => shell.openExternal(url))
return yield* Effect.tryPromise(() => shell.openExternal(url)).pipe(
Effect.as(true),
Effect.catch((error) =>
scoped("window", Effect.logError("failed to open external target", { url, error })).pipe(Effect.as(false)),
),
)
})
export const openLocalFileURL = Effect.fn("DesktopFiles.openLocalFileURL")(function* (value: string) {
@@ -24,7 +24,8 @@ export const fileHandlers = FileRpcs.toLayer(
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),
FilesOpenExternal: ({ url }) => openExternalURL(url).pipe(Effect.asVoid),
FilesOpenBrowser: ({ url }) => (/^https?:/i.test(url) ? openExternalURL(url) : Effect.succeed(false)),
FilesOpenLocalFile: ({ url }) => openLocalFileURL(url),
FilesOpenPath: ({ path, application }) =>
files.openPath(path, application).pipe(
@@ -0,0 +1,17 @@
import { describe, expect, test } from "bun:test"
import { consoleReturnWindow } from "./deep-link"
describe("Console return deep links", () => {
test("reads the originating Desktop window", () => {
expect(consoleReturnWindow("opencode://console/authorized?window=window-a")).toBe("window-a")
expect(consoleReturnWindow("opencode://console/authorized?window=window%20b")).toBe("window b")
})
test("rejects unrelated and malformed links", () => {
expect(consoleReturnWindow("opencode://console/other?window=window-a")).toBeUndefined()
expect(consoleReturnWindow("opencode://other/authorized?window=window-a")).toBeUndefined()
expect(consoleReturnWindow("https://console/authorized?window=window-a")).toBeUndefined()
expect(consoleReturnWindow("not a url")).toBeUndefined()
expect(consoleReturnWindow("opencode://console/authorized")).toBeUndefined()
})
})
@@ -0,0 +1,11 @@
export function consoleReturnWindow(value: string) {
try {
const url = new URL(value)
if (url.protocol !== "opencode:" || url.hostname !== "console" || url.pathname !== "/authorized") return
const id = url.searchParams.get("window")
if (!id || id.length > 256 || /[\u0000-\u001f\u007f]/.test(id)) return
return id
} catch {
return
}
}
+18 -8
View File
@@ -8,10 +8,11 @@ import { emitIpcEvent } from "../ipc-events"
import { DesktopLogging, scoped } from "../native/logging"
import { DesktopStorage } from "../storage"
import { safeWebContentsURL } from "../windows/state"
import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
import { getLastFocusedWindow, getWindowByID, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
import { marks } from "./marks"
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()
@@ -79,6 +79,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", () => {
@@ -31,6 +31,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
@@ -66,6 +66,7 @@ export type ElectronAPI = {
getPathForFile(file: File): string
saveFile(opts: SaveFilePickerOptions, content: string): Promise<boolean>
openExternal(url: string): void
openBrowser(url: string): Promise<boolean>
openLocalFile(url: string): void
openPath(path: string, app?: string): Promise<string | undefined>
revealPath(path: string): Promise<boolean>
+1
View File
@@ -134,6 +134,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")
@@ -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) {
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") {
@@ -43,6 +43,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 },
})
@@ -68,6 +72,7 @@ export const FileRpcs = RpcGroup.make(
FilesReleasePickedFiles,
FilesSaveFile,
FilesOpenExternal,
FilesOpenBrowser,
FilesOpenLocalFile,
FilesOpenPath,
FilesRevealPath,
+11 -2
View File
@@ -28,6 +28,12 @@ type Active = {
}
const Context = createContext<ReturnType<typeof init>>()
// Lets the dialog rendered in a layer opt out of closing on a backdrop click.
const LayerContext = createContext<{ setBackdropDismiss: (value: boolean) => void }>()
export function useDialogLayer() {
return useContext(LayerContext)
}
function init() {
const [stack, setStack] = createSignal<Active[]>([])
@@ -86,6 +92,7 @@ function init() {
createRoot((d: () => void) => {
dispose = d
const [closing, setClosingSignal] = createSignal(false)
const [backdropDismiss, setBackdropDismiss] = createSignal(true)
setClosing = setClosingSignal
return (
<Kobalte
@@ -100,7 +107,9 @@ function init() {
<Kobalte.Overlay
data-component="dialog-overlay"
style={{ "z-index": String(zIndex) }}
onClick={() => close(id)}
onClick={() => {
if (backdropDismiss()) close(id)
}}
/>
<div
data-dialog-layer={layer}
@@ -114,7 +123,7 @@ function init() {
"pointer-events": "none",
}}
>
{element()}
<LayerContext.Provider value={{ setBackdropDismiss }}>{element()}</LayerContext.Provider>
</div>
</Kobalte.Portal>
</Kobalte>
+23 -4
View File
@@ -1,5 +1,6 @@
import { CloseButton, Content, Description, Title } from "@kobalte/core/dialog"
import { type ComponentProps, type JSXElement, type ParentProps, Show, children, splitProps } from "solid-js"
import { type ComponentProps, type JSXElement, type ParentProps, Show, children, createEffect, splitProps } from "solid-js"
import { useDialogLayer } from "../../context/dialog"
import { useI18n } from "../../context/i18n"
import "./dialog.css"
@@ -10,6 +11,8 @@ export interface DialogProps extends ParentProps {
containerClass?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
fit?: boolean
onCloseAutoFocus?: ComponentProps<typeof Content>["onCloseAutoFocus"]
preventBackdropDismiss?: boolean
}
export interface DialogHeaderProps extends ParentProps {
@@ -19,7 +22,7 @@ export interface DialogHeaderProps extends ParentProps {
export interface DialogTitleGroupProps {
title?: JSXElement
description: JSXElement
description?: JSXElement
}
export function DialogFooter(props: ParentProps) {
@@ -46,7 +49,7 @@ export function DialogTitleGroup(props: DialogTitleGroupProps) {
return (
<div data-slot="dialog-title-group">
<Show when={title()}>{(t) => <Title data-slot="dialog-title">{t()}</Title>}</Show>
<Description data-slot="dialog-description">{description()}</Description>
<Show when={description()}>{(value) => <Description data-slot="dialog-description">{value()}</Description>}</Show>
</div>
)
}
@@ -82,7 +85,19 @@ 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",
"preventBackdropDismiss",
])
const layer = useDialogLayer()
createEffect(() => layer?.setBackdropDismiss(!local.preventBackdropDismiss))
return (
<div
@@ -94,6 +109,10 @@ export function Dialog(props: DialogProps) {
<div data-slot="dialog-container" class={local.containerClass}>
<Content
data-slot="dialog-content"
onCloseAutoFocus={local.onCloseAutoFocus}
onPointerDownOutside={(event) => {
if (local.preventBackdropDismiss) event.preventDefault()
}}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,