mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user