summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-14 05:40:43 -0600
committerAdam <[email protected]>2025-12-14 21:38:58 -0600
commit4a8e8f537ca688cca52674a619065f577cbd3f9b (patch)
tree2255aa8ab0a9bb9d90519a1ac6d35e60b1e3e6f0
parenta68bee7878d78ac7d480d6fbbd3225759e695c61 (diff)
downloadopencode-4a8e8f537ca688cca52674a619065f577cbd3f9b.tar.gz
opencode-4a8e8f537ca688cca52674a619065f577cbd3f9b.zip
wip(desktop): progress
-rw-r--r--packages/desktop/src/components/dialog-connect.tsx406
-rw-r--r--packages/desktop/src/components/dialog-model.tsx212
-rw-r--r--packages/desktop/src/components/dialog-provider.tsx68
-rw-r--r--packages/desktop/src/components/prompt-input.tsx208
-rw-r--r--packages/desktop/src/context/layout.tsx9
-rw-r--r--packages/desktop/src/context/local.tsx93
-rw-r--r--packages/desktop/src/pages/layout.tsx480
-rw-r--r--packages/opencode/src/provider/provider.ts4
-rw-r--r--packages/ui/src/components/select-dialog.tsx11
-rw-r--r--packages/ui/src/components/session-turn.tsx23
10 files changed, 784 insertions, 730 deletions
diff --git a/packages/desktop/src/components/dialog-connect.tsx b/packages/desktop/src/components/dialog-connect.tsx
new file mode 100644
index 000000000..a44365069
--- /dev/null
+++ b/packages/desktop/src/components/dialog-connect.tsx
@@ -0,0 +1,406 @@
+import { Component, createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { useLayout } from "@/context/layout"
+import { useGlobalSync } from "@/context/global-sync"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { usePlatform } from "@/context/platform"
+import { ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { List, ListRef } from "@opencode-ai/ui/list"
+import { Button } from "@opencode-ai/ui/button"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { TextField } from "@opencode-ai/ui/text-field"
+import { Spinner } from "@opencode-ai/ui/spinner"
+import { Icon } from "@opencode-ai/ui/icon"
+import { showToast } from "@opencode-ai/ui/toast"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
+import { IconName } from "@opencode-ai/ui/icons/provider"
+import { iife } from "@opencode-ai/util/iife"
+import { Link } from "@/components/link"
+
+export const DialogConnect: Component = () => {
+ const layout = useLayout()
+ const globalSync = useGlobalSync()
+ const globalSDK = useGlobalSDK()
+ const platform = usePlatform()
+
+ const providerID = createMemo(() => layout.connect.provider()!)
+ const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === providerID())!)
+ const methods = createMemo(
+ () =>
+ globalSync.data.provider_auth[providerID()] ?? [
+ {
+ type: "api",
+ label: "API key",
+ },
+ ],
+ )
+ const [store, setStore] = createStore({
+ method: undefined as undefined | ProviderAuthMethod,
+ authorization: undefined as undefined | ProviderAuthAuthorization,
+ state: "pending" as undefined | "pending" | "complete" | "error",
+ error: undefined as string | undefined,
+ })
+
+ const methodIndex = createMemo(() => methods().findIndex((x) => x.label === store.method?.label))
+
+ async function selectMethod(index: number) {
+ const method = methods()[index]
+ setStore(
+ produce((draft) => {
+ draft.method = method
+ draft.authorization = undefined
+ draft.state = undefined
+ draft.error = undefined
+ }),
+ )
+
+ if (method.type === "oauth") {
+ setStore("state", "pending")
+ const start = Date.now()
+ await globalSDK.client.provider.oauth
+ .authorize(
+ {
+ providerID: providerID(),
+ method: index,
+ },
+ { throwOnError: true },
+ )
+ .then((x) => {
+ const elapsed = Date.now() - start
+ const delay = 1000 - elapsed
+
+ if (delay > 0) {
+ setTimeout(() => {
+ setStore("state", "complete")
+ setStore("authorization", x.data!)
+ }, delay)
+ return
+ }
+ setStore("state", "complete")
+ setStore("authorization", x.data!)
+ })
+ .catch((e) => {
+ setStore("state", "error")
+ setStore("error", String(e))
+ })
+ }
+ }
+
+ let listRef: ListRef | undefined
+ function handleKey(e: KeyboardEvent) {
+ if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
+ return
+ }
+ if (e.key === "Escape") return
+ listRef?.onKeyDown(e)
+ }
+
+ onMount(() => {
+ if (methods().length === 1) {
+ selectMethod(0)
+ }
+
+ document.addEventListener("keydown", handleKey)
+ onCleanup(() => {
+ document.removeEventListener("keydown", handleKey)
+ })
+ })
+
+ async function complete() {
+ await globalSDK.client.global.dispose()
+ setTimeout(() => {
+ showToast({
+ variant: "success",
+ icon: "circle-check",
+ title: `${provider().name} connected`,
+ description: `${provider().name} models are now available to use.`,
+ })
+ layout.connect.complete()
+ }, 500)
+ }
+
+ return (
+ <Dialog
+ modal
+ defaultOpen
+ onOpenChange={(open) => {
+ if (open) {
+ layout.dialog.open("connect")
+ } else {
+ layout.dialog.close("connect")
+ }
+ }}
+ >
+ <Dialog.Header class="px-4.5">
+ <Dialog.Title class="flex items-center">
+ <IconButton
+ tabIndex={-1}
+ icon="arrow-left"
+ variant="ghost"
+ onClick={() => {
+ if (methods().length === 1) {
+ layout.dialog.open("provider")
+ return
+ }
+ if (store.authorization) {
+ setStore("authorization", undefined)
+ setStore("method", undefined)
+ return
+ }
+ if (store.method) {
+ setStore("method", undefined)
+ return
+ }
+ layout.dialog.open("provider")
+ }}
+ />
+ </Dialog.Title>
+ <Dialog.CloseButton tabIndex={-1} />
+ </Dialog.Header>
+ <Dialog.Body>
+ <div class="flex flex-col gap-6 px-2.5 pb-3">
+ <div class="px-2.5 flex gap-4 items-center">
+ <ProviderIcon id={providerID() as IconName} class="size-5 shrink-0 icon-strong-base" />
+ <div class="text-16-medium text-text-strong">
+ <Switch>
+ <Match when={providerID() === "anthropic" && store.method?.label?.toLowerCase().includes("max")}>
+ Login with Claude Pro/Max
+ </Match>
+ <Match when={true}>Connect {provider().name}</Match>
+ </Switch>
+ </div>
+ </div>
+ <div class="px-2.5 pb-10 flex flex-col gap-6">
+ <Switch>
+ <Match when={store.method === undefined}>
+ <div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
+ <div class="">
+ <List
+ ref={(ref) => (listRef = ref)}
+ items={methods}
+ key={(m) => m?.label}
+ onSelect={async (method, index) => {
+ if (!method) return
+ selectMethod(index)
+ }}
+ >
+ {(i) => (
+ <div class="w-full flex items-center gap-x-4">
+ <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
+ <div class="w-2.5 h-0.5 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
+ </div>
+ <span>{i.label}</span>
+ </div>
+ )}
+ </List>
+ </div>
+ </Match>
+ <Match when={store.state === "pending"}>
+ <div class="text-14-regular text-text-base">
+ <div class="flex items-center gap-x-4">
+ <Spinner />
+ <span>Authorization in progress...</span>
+ </div>
+ </div>
+ </Match>
+ <Match when={store.state === "error"}>
+ <div class="text-14-regular text-text-base">
+ <div class="flex items-center gap-x-4">
+ <Icon name="circle-ban-sign" class="text-icon-critical-base" />
+ <span>Authorization failed: {store.error}</span>
+ </div>
+ </div>
+ </Match>
+ <Match when={store.method?.type === "api"}>
+ {iife(() => {
+ const [formStore, setFormStore] = createStore({
+ value: "",
+ error: undefined as string | undefined,
+ })
+
+ async function handleSubmit(e: SubmitEvent) {
+ e.preventDefault()
+
+ const form = e.currentTarget as HTMLFormElement
+ const formData = new FormData(form)
+ const apiKey = formData.get("apiKey") as string
+
+ if (!apiKey?.trim()) {
+ setFormStore("error", "API key is required")
+ return
+ }
+
+ setFormStore("error", undefined)
+ await globalSDK.client.auth.set({
+ providerID: providerID(),
+ auth: {
+ type: "api",
+ key: apiKey,
+ },
+ })
+ await complete()
+ }
+
+ return (
+ <div class="flex flex-col gap-6">
+ <Switch>
+ <Match when={provider().id === "opencode"}>
+ <div class="flex flex-col gap-4">
+ <div class="text-14-regular text-text-base">
+ OpenCode Zen gives you access to a curated set of reliable optimized models for coding
+ agents.
+ </div>
+ <div class="text-14-regular text-text-base">
+ With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and
+ more.
+ </div>
+ <div class="text-14-regular text-text-base">
+ Visit{" "}
+ <Link href="https://opencode.ai/zen" tabIndex={-1}>
+ opencode.ai/zen
+ </Link>{" "}
+ to collect your API key.
+ </div>
+ </div>
+ </Match>
+ <Match when={true}>
+ <div class="text-14-regular text-text-base">
+ Enter your {provider().name} API key to connect your account and use {provider().name}{" "}
+ models in OpenCode.
+ </div>
+ </Match>
+ </Switch>
+ <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
+ <TextField
+ autofocus
+ type="text"
+ label={`${provider().name} API key`}
+ placeholder="API key"
+ name="apiKey"
+ value={formStore.value}
+ onChange={setFormStore.bind(null, "value")}
+ validationState={formStore.error ? "invalid" : undefined}
+ error={formStore.error}
+ />
+ <Button class="w-auto" type="submit" size="large" variant="primary">
+ Submit
+ </Button>
+ </form>
+ </div>
+ )
+ })}
+ </Match>
+ <Match when={store.method?.type === "oauth"}>
+ <Switch>
+ <Match when={store.authorization?.method === "code"}>
+ {iife(() => {
+ const [formStore, setFormStore] = createStore({
+ value: "",
+ error: undefined as string | undefined,
+ })
+
+ onMount(() => {
+ if (store.authorization?.method === "code" && store.authorization?.url) {
+ platform.openLink(store.authorization.url)
+ }
+ })
+
+ async function handleSubmit(e: SubmitEvent) {
+ e.preventDefault()
+
+ const form = e.currentTarget as HTMLFormElement
+ const formData = new FormData(form)
+ const code = formData.get("code") as string
+
+ if (!code?.trim()) {
+ setFormStore("error", "Authorization code is required")
+ return
+ }
+
+ setFormStore("error", undefined)
+ const { error } = await globalSDK.client.provider.oauth.callback({
+ providerID: providerID(),
+ method: methodIndex(),
+ code,
+ })
+ if (!error) {
+ await complete()
+ return
+ }
+ setFormStore("error", "Invalid authorization code")
+ }
+
+ return (
+ <div class="flex flex-col gap-6">
+ <div class="text-14-regular text-text-base">
+ Visit <Link href={store.authorization!.url}>this link</Link> to collect your authorization
+ code to connect your account and use {provider().name} models in OpenCode.
+ </div>
+ <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
+ <TextField
+ autofocus
+ type="text"
+ label={`${store.method?.label} authorization code`}
+ placeholder="Authorization code"
+ name="code"
+ value={formStore.value}
+ onChange={setFormStore.bind(null, "value")}
+ validationState={formStore.error ? "invalid" : undefined}
+ error={formStore.error}
+ />
+ <Button class="w-auto" type="submit" size="large" variant="primary">
+ Submit
+ </Button>
+ </form>
+ </div>
+ )
+ })}
+ </Match>
+ <Match when={store.authorization?.method === "auto"}>
+ {iife(() => {
+ const code = createMemo(() => {
+ const instructions = store.authorization?.instructions
+ if (instructions?.includes(":")) {
+ return instructions?.split(":")[1]?.trim()
+ }
+ return instructions
+ })
+
+ onMount(async () => {
+ const result = await globalSDK.client.provider.oauth.callback({
+ providerID: providerID(),
+ method: methodIndex(),
+ })
+ if (result.error) {
+ // TODO: show error
+ layout.dialog.close("connect")
+ return
+ }
+ await complete()
+ })
+
+ return (
+ <div class="flex flex-col gap-6">
+ <div class="text-14-regular text-text-base">
+ Visit <Link href={store.authorization!.url}>this link</Link> and enter the code below to
+ connect your account and use {provider().name} models in OpenCode.
+ </div>
+ <TextField label="Confirmation code" class="font-mono" value={code()} readOnly copyable />
+ <div class="text-14-regular text-text-base flex items-center gap-4">
+ <Spinner />
+ <span>Waiting for authorization...</span>
+ </div>
+ </div>
+ )
+ })}
+ </Match>
+ </Switch>
+ </Match>
+ </Switch>
+ </div>
+ </div>
+ </Dialog.Body>
+ </Dialog>
+ )
+}
diff --git a/packages/desktop/src/components/dialog-model.tsx b/packages/desktop/src/components/dialog-model.tsx
new file mode 100644
index 000000000..9d36e0797
--- /dev/null
+++ b/packages/desktop/src/components/dialog-model.tsx
@@ -0,0 +1,212 @@
+import { Component, createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js"
+import { useLocal } from "@/context/local"
+import { useLayout } from "@/context/layout"
+import { popularProviders, useProviders } from "@/hooks/use-providers"
+import { SelectDialog } from "@opencode-ai/ui/select-dialog"
+import { Button } from "@opencode-ai/ui/button"
+import { Tag } from "@opencode-ai/ui/tag"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { List, ListRef } from "@opencode-ai/ui/list"
+import { iife } from "@opencode-ai/util/iife"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
+import { IconName } from "@opencode-ai/ui/icons/provider"
+
+export const DialogModel: Component = () => {
+ const local = useLocal()
+ const layout = useLayout()
+ const providers = useProviders()
+
+ return (
+ <Switch>
+ <Match when={providers.paid().length > 0}>
+ {iife(() => {
+ const models = createMemo(() =>
+ local.model
+ .list()
+ .filter((m) => m.visible)
+ .filter((m) =>
+ layout.connect.state() === "complete" ? m.provider.id === layout.connect.provider() : true,
+ ),
+ )
+ return (
+ <SelectDialog
+ defaultOpen
+ onOpenChange={(open) => {
+ if (open) {
+ layout.dialog.open("model")
+ } else {
+ layout.dialog.close("model")
+ }
+ }}
+ title="Select model"
+ placeholder="Search models"
+ emptyMessage="No model results"
+ key={(x) => `${x.provider.id}:${x.id}`}
+ items={models}
+ current={local.model.current()}
+ filterKeys={["provider.name", "name", "id"]}
+ sortBy={(a, b) => a.name.localeCompare(b.name)}
+ groupBy={(x) => x.provider.name}
+ sortGroupsBy={(a, b) => {
+ if (a.category === "Recent" && b.category !== "Recent") return -1
+ if (b.category === "Recent" && a.category !== "Recent") return 1
+ const aProvider = a.items[0].provider.id
+ const bProvider = b.items[0].provider.id
+ if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
+ if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
+ return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
+ }}
+ onSelect={(x) =>
+ local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
+ recent: true,
+ })
+ }
+ actions={
+ <Button
+ class="h-7 -my-1 text-14-medium"
+ icon="plus-small"
+ tabIndex={-1}
+ onClick={() => layout.dialog.open("provider")}
+ >
+ Connect provider
+ </Button>
+ }
+ >
+ {(i) => (
+ <div class="w-full flex items-center gap-x-2.5">
+ <span>{i.name}</span>
+ <Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
+ <Tag>Free</Tag>
+ </Show>
+ <Show when={i.latest}>
+ <Tag>Latest</Tag>
+ </Show>
+ </div>
+ )}
+ </SelectDialog>
+ )
+ })}
+ </Match>
+ <Match when={true}>
+ {iife(() => {
+ let listRef: ListRef | undefined
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") return
+ listRef?.onKeyDown(e)
+ }
+
+ onMount(() => {
+ document.addEventListener("keydown", handleKey)
+ onCleanup(() => {
+ document.removeEventListener("keydown", handleKey)
+ })
+ })
+
+ return (
+ <Dialog
+ modal
+ defaultOpen
+ onOpenChange={(open) => {
+ if (open) {
+ layout.dialog.open("model")
+ } else {
+ layout.dialog.close("model")
+ }
+ }}
+ >
+ <Dialog.Header>
+ <Dialog.Title>Select model</Dialog.Title>
+ <Dialog.CloseButton tabIndex={-1} />
+ </Dialog.Header>
+ <Dialog.Body>
+ <div class="flex flex-col gap-3 px-2.5">
+ <div class="text-14-medium text-text-base px-2.5">Free models provided by OpenCode</div>
+ <List
+ ref={(ref) => (listRef = ref)}
+ items={local.model.list}
+ current={local.model.current()}
+ key={(x) => `${x.provider.id}:${x.id}`}
+ onSelect={(x) => {
+ local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
+ recent: true,
+ })
+ layout.dialog.close("model")
+ }}
+ >
+ {(i) => (
+ <div class="w-full flex items-center gap-x-2.5">
+ <span>{i.name}</span>
+ <Tag>Free</Tag>
+ <Show when={i.latest}>
+ <Tag>Latest</Tag>
+ </Show>
+ </div>
+ )}
+ </List>
+ <div />
+ <div />
+ </div>
+ <div class="px-1.5 pb-1.5">
+ <div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
+ <div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4">
+ <div class="px-2 text-14-medium text-text-base">Add more models from popular providers</div>
+ <div class="w-full">
+ <List
+ class="w-full"
+ key={(x) => x?.id}
+ items={providers.popular}
+ activeIcon="plus-small"
+ sortBy={(a, b) => {
+ if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
+ return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
+ return a.name.localeCompare(b.name)
+ }}
+ onSelect={(x) => {
+ if (!x) return
+ layout.dialog.connect(x.id)
+ }}
+ >
+ {(i) => (
+ <div class="w-full flex items-center gap-x-4">
+ <ProviderIcon
+ data-slot="list-item-extra-icon"
+ id={i.id as IconName}
+ // TODO: clean this up after we update icon in models.dev
+ classList={{
+ "text-icon-weak-base": true,
+ "size-4 mx-0.5": i.id === "opencode",
+ "size-5": i.id !== "opencode",
+ }}
+ />
+ <span>{i.name}</span>
+ <Show when={i.id === "opencode"}>
+ <Tag>Recommended</Tag>
+ </Show>
+ <Show when={i.id === "anthropic"}>
+ <div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div>
+ </Show>
+ </div>
+ )}
+ </List>
+ <Button
+ variant="ghost"
+ class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
+ icon="dot-grid"
+ onClick={() => {
+ layout.dialog.open("provider")
+ }}
+ >
+ View all providers
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </Dialog.Body>
+ </Dialog>
+ )
+ })}
+ </Match>
+ </Switch>
+ )
+}
diff --git a/packages/desktop/src/components/dialog-provider.tsx b/packages/desktop/src/components/dialog-provider.tsx
new file mode 100644
index 000000000..56c791479
--- /dev/null
+++ b/packages/desktop/src/components/dialog-provider.tsx
@@ -0,0 +1,68 @@
+import { Component, Show } from "solid-js"
+import { useLayout } from "@/context/layout"
+import { popularProviders, useProviders } from "@/hooks/use-providers"
+import { SelectDialog } from "@opencode-ai/ui/select-dialog"
+import { Tag } from "@opencode-ai/ui/tag"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
+import { IconName } from "@opencode-ai/ui/icons/provider"
+
+export const DialogProvider: Component = () => {
+ const layout = useLayout()
+ const providers = useProviders()
+
+ return (
+ <SelectDialog
+ defaultOpen
+ title="Connect provider"
+ placeholder="Search providers"
+ activeIcon="plus-small"
+ key={(x) => x?.id}
+ items={providers.all}
+ filterKeys={["id", "name"]}
+ groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")}
+ sortBy={(a, b) => {
+ if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
+ return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
+ return a.name.localeCompare(b.name)
+ }}
+ sortGroupsBy={(a, b) => {
+ if (a.category === "Popular" && b.category !== "Popular") return -1
+ if (b.category === "Popular" && a.category !== "Popular") return 1
+ return 0
+ }}
+ onSelect={(x) => {
+ if (!x) return
+ layout.dialog.connect(x.id)
+ }}
+ onOpenChange={(open) => {
+ if (open) {
+ layout.dialog.open("provider")
+ } else {
+ layout.dialog.close("provider")
+ }
+ }}
+ >
+ {(i) => (
+ <div class="px-1.25 w-full flex items-center gap-x-4">
+ <ProviderIcon
+ data-slot="list-item-extra-icon"
+ id={i.id as IconName}
+ // TODO: clean this up after we update icon in models.dev
+ classList={{
+ "text-icon-weak-base": true,
+ "size-4 mx-0.5": i.id === "opencode",
+ "size-5": i.id !== "opencode",
+ }}
+ />
+ <span>{i.name}</span>
+ <Show when={i.id === "opencode"}>
+ <Tag>Recommended</Tag>
+ </Show>
+ <Show when={i.id === "anthropic"}>
+ <div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div>
+ </Show>
+ </div>
+ )}
+ </SelectDialog>
+ )
+}
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 114e6d49d..7c60a6d01 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -1,5 +1,5 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
-import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js"
+import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
import { createStore } from "solid-js/store"
import { makePersisted } from "@solid-primitives/storage"
import { createFocusSignal } from "@solid-primitives/active-element"
@@ -9,21 +9,14 @@ import { useSDK } from "@/context/sdk"
import { useNavigate } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { FileIcon } from "@opencode-ai/ui/file-icon"
-import { SelectDialog } from "@opencode-ai/ui/select-dialog"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
-import { Tag } from "@opencode-ai/ui/tag"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useLayout } from "@/context/layout"
-import { popularProviders, useProviders } from "@/hooks/use-providers"
-import { Dialog } from "@opencode-ai/ui/dialog"
-import { List, ListRef } from "@opencode-ai/ui/list"
-import { iife } from "@opencode-ai/util/iife"
-import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import { IconName } from "@opencode-ai/ui/icons/provider"
+import { DialogModel } from "@/components/dialog-model"
interface PromptInputProps {
class?: string
@@ -65,7 +58,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const local = useLocal()
const session = useSession()
const layout = useLayout()
- const providers = useProviders()
let editorRef!: HTMLDivElement
const [store, setStore] = createStore<{
@@ -624,201 +616,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Icon name="chevron-down" size="small" />
</Button>
<Show when={layout.dialog.opened() === "model"}>
- <Switch>
- <Match when={providers.paid().length > 0}>
- {iife(() => {
- const models = createMemo(() =>
- local.model
- .list()
- .filter((m) =>
- layout.connect.state() === "complete" ? m.provider.id === layout.connect.provider() : true,
- ),
- )
- return (
- <SelectDialog
- defaultOpen
- onOpenChange={(open) => {
- if (open) {
- layout.dialog.open("model")
- } else {
- layout.dialog.close("model")
- }
- }}
- title="Select model"
- placeholder="Search models"
- emptyMessage="No model results"
- key={(x) => `${x.provider.id}:${x.id}`}
- items={models}
- current={local.model.current()}
- filterKeys={["provider.name", "name", "id"]}
- sortBy={(a, b) => a.name.localeCompare(b.name)}
- // groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)}
- groupBy={(x) => x.provider.name}
- sortGroupsBy={(a, b) => {
- if (a.category === "Recent" && b.category !== "Recent") return -1
- if (b.category === "Recent" && a.category !== "Recent") return 1
- const aProvider = a.items[0].provider.id
- const bProvider = b.items[0].provider.id
- if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
- if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
- return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
- }}
- onSelect={(x) =>
- local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
- recent: true,
- })
- }
- actions={
- <Button
- class="h-7 -my-1 text-14-medium"
- icon="plus-small"
- tabIndex={-1}
- onClick={() => layout.dialog.open("provider")}
- >
- Connect provider
- </Button>
- }
- >
- {(i) => (
- <div class="w-full flex items-center gap-x-2.5">
- <span>{i.name}</span>
- <Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
- <Tag>Free</Tag>
- </Show>
- <Show when={i.latest}>
- <Tag>Latest</Tag>
- </Show>
- </div>
- )}
- </SelectDialog>
- )
- })}
- </Match>
- <Match when={true}>
- {iife(() => {
- let listRef: ListRef | undefined
- const handleKey = (e: KeyboardEvent) => {
- if (e.key === "Escape") return
- listRef?.onKeyDown(e)
- }
-
- onMount(() => {
- document.addEventListener("keydown", handleKey)
- onCleanup(() => {
- document.removeEventListener("keydown", handleKey)
- })
- })
-
- return (
- <Dialog
- modal
- defaultOpen
- onOpenChange={(open) => {
- if (open) {
- layout.dialog.open("model")
- } else {
- layout.dialog.close("model")
- }
- }}
- >
- <Dialog.Header>
- <Dialog.Title>Select model</Dialog.Title>
- <Dialog.CloseButton tabIndex={-1} />
- </Dialog.Header>
- <Dialog.Body>
- <div class="flex flex-col gap-3 px-2.5">
- <div class="text-14-medium text-text-base px-2.5">Free models provided by OpenCode</div>
- <List
- ref={(ref) => (listRef = ref)}
- items={local.model.list}
- current={local.model.current()}
- key={(x) => `${x.provider.id}:${x.id}`}
- onSelect={(x) => {
- local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
- recent: true,
- })
- layout.dialog.close("model")
- }}
- >
- {(i) => (
- <div class="w-full flex items-center gap-x-2.5">
- <span>{i.name}</span>
- <Tag>Free</Tag>
- <Show when={i.latest}>
- <Tag>Latest</Tag>
- </Show>
- </div>
- )}
- </List>
- <div />
- <div />
- </div>
- <div class="px-1.5 pb-1.5">
- <div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
- <div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4">
- <div class="px-2 text-14-medium text-text-base">
- Add more models from popular providers
- </div>
- <div class="w-full">
- <List
- class="w-full"
- key={(x) => x?.id}
- items={providers.popular}
- activeIcon="plus-small"
- sortBy={(a, b) => {
- if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
- return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
- return a.name.localeCompare(b.name)
- }}
- onSelect={(x) => {
- if (!x) return
- layout.dialog.connect(x.id)
- }}
- >
- {(i) => (
- <div class="w-full flex items-center gap-x-4">
- <ProviderIcon
- data-slot="list-item-extra-icon"
- id={i.id as IconName}
- // TODO: clean this up after we update icon in models.dev
- classList={{
- "text-icon-weak-base": true,
- "size-4 mx-0.5": i.id === "opencode",
- "size-5": i.id !== "opencode",
- }}
- />
- <span>{i.name}</span>
- <Show when={i.id === "opencode"}>
- <Tag>Recommended</Tag>
- </Show>
- <Show when={i.id === "anthropic"}>
- <div class="text-14-regular text-text-weak">
- Connect with Claude Pro/Max or API key
- </div>
- </Show>
- </div>
- )}
- </List>
- <Button
- variant="ghost"
- class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
- icon="dot-grid"
- onClick={() => {
- layout.dialog.open("provider")
- }}
- >
- View all providers
- </Button>
- </div>
- </div>
- </div>
- </div>
- </Dialog.Body>
- </Dialog>
- )
- })}
- </Match>
- </Switch>
+ <DialogModel />
</Show>
</div>
<Tooltip
diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx
index 3d5cad761..587276c53 100644
--- a/packages/desktop/src/context/layout.tsx
+++ b/packages/desktop/src/context/layout.tsx
@@ -22,7 +22,7 @@ export function getAvatarColors(key?: string) {
}
}
-type Dialog = "provider" | "model" | "connect"
+type Dialog = "provider" | "model" | "connect" | "manage-models"
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
@@ -172,12 +172,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
dialog: {
opened: createMemo(() => ephemeral.dialog?.open),
open(dialog: Dialog) {
- batch(() => {
- // if (dialog !== "connect") {
- // setEphemeral("connect", {})
- // }
- setEphemeral("dialog", "open", dialog)
- })
+ setEphemeral("dialog", "open", dialog)
},
close(dialog: Dialog) {
if (ephemeral.dialog.open === dialog) {
diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx
index 181a4d247..f841da1cc 100644
--- a/packages/desktop/src/context/local.tsx
+++ b/packages/desktop/src/context/local.tsx
@@ -1,12 +1,14 @@
import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo } from "solid-js"
-import { uniqueBy } from "remeda"
+import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers"
+import { makePersisted } from "@solid-primitives/storage"
+import { DateTime } from "luxon"
export type LocalFile = FileNode &
Partial<{
@@ -108,30 +110,66 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})()
const model = (() => {
- const [store, setStore] = createStore<{
+ const [store, setStore] = makePersisted(
+ createStore<{
+ user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
+ recent: ModelKey[]
+ }>({
+ user: [],
+ recent: [],
+ }),
+ { name: "model.v1" },
+ )
+
+ const [ephemeral, setEphemeral] = createStore<{
model: Record<string, ModelKey>
- recent: ModelKey[]
}>({
model: {},
- recent: [],
- })
-
- const value = localStorage.getItem("model")
- setStore("recent", JSON.parse(value ?? "[]"))
- createEffect(() => {
- localStorage.setItem("model", JSON.stringify(store.recent))
})
- const list = createMemo(() =>
+ const available = createMemo(() =>
providers.connected().flatMap((p) =>
Object.values(p.models).map((m) => ({
...m,
- name: m.name.replace("(latest)", "").trim(),
provider: p,
- latest: m.name.includes("(latest)"),
+ user: store.user.find((x) => x.modelID === m.id && x.providerID === p.id),
})),
),
)
+ const latest = createMemo(() =>
+ pipe(
+ available(),
+ filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6),
+ groupBy((x) => x.provider.id),
+ mapValues((models) =>
+ pipe(
+ models,
+ groupBy((x) => x.family),
+ values(),
+ (groups) =>
+ groups.flatMap((g) => {
+ const first = firstBy(g, [(x) => x.release_date, "desc"])
+ return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
+ }),
+ ),
+ ),
+ values(),
+ flat(),
+ ),
+ )
+
+ const list = createMemo(() =>
+ available().map((m) => ({
+ ...m,
+ name: m.name.replace("(latest)", "").trim(),
+ latest: m.name.includes("(latest)"),
+ visible:
+ m.user?.visibility !== "hide" &&
+ (latest().find((x) => x.modelID === m.id && x.providerID === m.provider.id) ||
+ store.user.find((x) => x.modelID === m.id && x.providerID === m.provider.id)?.visibility === "show"),
+ })),
+ )
+
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
const fallbackModel = createMemo(() => {
@@ -163,10 +201,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
throw new Error("No default model found")
})
- const currentModel = createMemo(() => {
+ const current = createMemo(() => {
const a = agent.current()
const key = getFirstValidModel(
- () => store.model[a.name],
+ () => ephemeral.model[a.name],
() => a.model,
fallbackModel,
)!
@@ -177,10 +215,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const cycle = (direction: 1 | -1) => {
const recentList = recent()
- const current = currentModel()
- if (!current) return
+ const currentModel = current()
+ if (!currentModel) return
- const index = recentList.findIndex((x) => x?.provider.id === current.provider.id && x?.id === current.id)
+ const index = recentList.findIndex(
+ (x) => x?.provider.id === currentModel.provider.id && x?.id === currentModel.id,
+ )
if (index === -1) return
let next = index + direction
@@ -196,14 +236,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
}
+ function updateVisibility(model: ModelKey, visibility: "show" | "hide") {
+ const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
+ if (index >= 0) {
+ setStore("user", index, { visibility: visibility })
+ }
+ }
+
return {
- current: currentModel,
+ current,
recent,
list,
cycle,
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
- setStore("model", agent.current().name, model ?? fallbackModel())
+ setEphemeral("model", agent.current().name, model ?? fallbackModel())
if (options?.recent && model) {
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop()
@@ -211,6 +258,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
})
},
+ show(model: ModelKey) {
+ updateVisibility(model, "show")
+ },
+ hide(model: ModelKey) {
+ updateVisibility(model, "hide")
+ },
}
})()
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index ef51361ba..63ee5b2aa 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -1,16 +1,4 @@
-import {
- createEffect,
- createMemo,
- createSignal,
- For,
- Match,
- onCleanup,
- onMount,
- ParentProps,
- Show,
- Switch,
- type JSX,
-} from "solid-js"
+import { createEffect, createMemo, createSignal, For, Match, ParentProps, Show, Switch, type JSX } from "solid-js"
import { DateTime } from "luxon"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout, getAvatarColors } from "@/context/layout"
@@ -20,14 +8,13 @@ import { Avatar } from "@opencode-ai/ui/avatar"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
-import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { getFilename } from "@opencode-ai/util/path"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
-import { Session, Project, ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
+import { Session, Project } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform"
import { createStore, produce } from "solid-js/store"
import {
@@ -40,21 +27,14 @@ import {
useDragDropContext,
} from "@thisbeyond/solid-dnd"
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
-import { SelectDialog } from "@opencode-ai/ui/select-dialog"
-import { Tag } from "@opencode-ai/ui/tag"
-import { IconName } from "@opencode-ai/ui/icons/provider"
-import { popularProviders, useProviders } from "@/hooks/use-providers"
-import { Dialog } from "@opencode-ai/ui/dialog"
-import { iife } from "@opencode-ai/util/iife"
-import { Link } from "@/components/link"
-import { List, ListRef } from "@opencode-ai/ui/list"
-import { TextField } from "@opencode-ai/ui/text-field"
-import { showToast, Toast } from "@opencode-ai/ui/toast"
+import { useProviders } from "@/hooks/use-providers"
+import { Toast } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk"
-import { Spinner } from "@opencode-ai/ui/spinner"
import { useNotification } from "@/context/notification"
import { Binary } from "@opencode-ai/util/binary"
import { Header } from "@/components/header"
+import { DialogProvider } from "@/components/dialog-provider"
+import { DialogConnect } from "@/components/dialog-connect"
export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
@@ -576,454 +556,10 @@ export default function Layout(props: ParentProps) {
</div>
<main class="size-full overflow-x-hidden flex flex-col items-start">{props.children}</main>
<Show when={layout.dialog.opened() === "provider"}>
- <SelectDialog
- defaultOpen
- title="Connect provider"
- placeholder="Search providers"
- activeIcon="plus-small"
- key={(x) => x?.id}
- items={providers.all}
- filterKeys={["id", "name"]}
- groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")}
- sortBy={(a, b) => {
- if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
- return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
- return a.name.localeCompare(b.name)
- }}
- sortGroupsBy={(a, b) => {
- if (a.category === "Popular" && b.category !== "Popular") return -1
- if (b.category === "Popular" && a.category !== "Popular") return 1
- return 0
- }}
- onSelect={(x) => {
- if (!x) return
- layout.dialog.connect(x.id)
- }}
- onOpenChange={(open) => {
- if (open) {
- layout.dialog.open("provider")
- } else {
- layout.dialog.close("provider")
- }
- }}
- >
- {(i) => (
- <div class="px-1.25 w-full flex items-center gap-x-4">
- <ProviderIcon
- data-slot="list-item-extra-icon"
- id={i.id as IconName}
- // TODO: clean this up after we update icon in models.dev
- classList={{
- "text-icon-weak-base": true,
- "size-4 mx-0.5": i.id === "opencode",
- "size-5": i.id !== "opencode",
- }}
- />
- <span>{i.name}</span>
- <Show when={i.id === "opencode"}>
- <Tag>Recommended</Tag>
- </Show>
- <Show when={i.id === "anthropic"}>
- <div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div>
- </Show>
- </div>
- )}
- </SelectDialog>
+ <DialogProvider />
</Show>
<Show when={layout.dialog.opened() === "connect"}>
- {iife(() => {
- const providerID = createMemo(() => layout.connect.provider()!)
- const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === providerID())!)
- const methods = createMemo(
- () =>
- globalSync.data.provider_auth[providerID()] ?? [
- {
- type: "api",
- label: "API key",
- },
- ],
- )
- const [store, setStore] = createStore({
- method: undefined as undefined | ProviderAuthMethod,
- authorization: undefined as undefined | ProviderAuthAuthorization,
- state: "pending" as undefined | "pending" | "complete" | "error",
- error: undefined as string | undefined,
- })
-
- const methodIndex = createMemo(() => methods().findIndex((x) => x.label === store.method?.label))
-
- async function selectMethod(index: number) {
- const method = methods()[index]
- setStore(
- produce((draft) => {
- draft.method = method
- draft.authorization = undefined
- draft.state = undefined
- draft.error = undefined
- }),
- )
-
- if (method.type === "oauth") {
- setStore("state", "pending")
- const start = Date.now()
- await globalSDK.client.provider.oauth
- .authorize(
- {
- providerID: providerID(),
- method: index,
- },
- { throwOnError: true },
- )
- .then((x) => {
- const elapsed = Date.now() - start
- const delay = 1000 - elapsed
-
- if (delay > 0) {
- setTimeout(() => {
- setStore("state", "complete")
- setStore("authorization", x.data!)
- }, delay)
- return
- }
- setStore("state", "complete")
- setStore("authorization", x.data!)
- })
- .catch((e) => {
- setStore("state", "error")
- setStore("error", String(e))
- })
- }
- }
-
- let listRef: ListRef | undefined
- function handleKey(e: KeyboardEvent) {
- if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
- return
- }
- if (e.key === "Escape") return
- listRef?.onKeyDown(e)
- }
-
- onMount(() => {
- if (methods().length === 1) {
- selectMethod(0)
- }
-
- document.addEventListener("keydown", handleKey)
- onCleanup(() => {
- document.removeEventListener("keydown", handleKey)
- })
- })
-
- async function complete() {
- await globalSDK.client.global.dispose()
- setTimeout(() => {
- showToast({
- variant: "success",
- icon: "circle-check",
- title: `${provider().name} connected`,
- description: `${provider().name} models are now available to use.`,
- })
- layout.connect.complete()
- }, 500)
- }
-
- return (
- <Dialog
- modal
- defaultOpen
- onOpenChange={(open) => {
- if (open) {
- layout.dialog.open("connect")
- } else {
- layout.dialog.close("connect")
- }
- }}
- >
- <Dialog.Header class="px-4.5">
- <Dialog.Title class="flex items-center">
- <IconButton
- tabIndex={-1}
- icon="arrow-left"
- variant="ghost"
- onClick={() => {
- if (methods().length === 1) {
- layout.dialog.open("provider")
- return
- }
- if (store.authorization) {
- setStore("authorization", undefined)
- setStore("method", undefined)
- return
- }
- if (store.method) {
- setStore("method", undefined)
- return
- }
- layout.dialog.open("provider")
- }}
- />
- </Dialog.Title>
- <Dialog.CloseButton tabIndex={-1} />
- </Dialog.Header>
- <Dialog.Body>
- <div class="flex flex-col gap-6 px-2.5 pb-3">
- <div class="px-2.5 flex gap-4 items-center">
- <ProviderIcon id={providerID() as IconName} class="size-5 shrink-0 icon-strong-base" />
- <div class="text-16-medium text-text-strong">
- <Switch>
- <Match
- when={providerID() === "anthropic" && store.method?.label?.toLowerCase().includes("max")}
- >
- Login with Claude Pro/Max
- </Match>
- <Match when={true}>Connect {provider().name}</Match>
- </Switch>
- </div>
- </div>
- <div class="px-2.5 pb-10 flex flex-col gap-6">
- <Switch>
- <Match when={store.method === undefined}>
- <div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
- <div class="">
- <List
- ref={(ref) => (listRef = ref)}
- items={methods}
- key={(m) => m?.label}
- onSelect={async (method, index) => {
- if (!method) return
- selectMethod(index)
- }}
- >
- {(i) => (
- <div class="w-full flex items-center gap-x-4">
- <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
- <div
- class="w-2.5 h-0.5 bg-icon-strong-base hidden"
- data-slot="list-item-extra-icon"
- />
- </div>
- <span>{i.label}</span>
- </div>
- )}
- </List>
- </div>
- </Match>
- <Match when={store.state === "pending"}>
- <div class="text-14-regular text-text-base">
- <div class="flex items-center gap-x-4">
- <Spinner />
- <span>Authorization in progress...</span>
- </div>
- </div>
- </Match>
- <Match when={store.state === "error"}>
- <div class="text-14-regular text-text-base">
- <div class="flex items-center gap-x-4">
- <Icon name="circle-ban-sign" class="text-icon-critical-base" />
- <span>Authorization failed: {store.error}</span>
- </div>
- </div>
- </Match>
- <Match when={store.method?.type === "api"}>
- {iife(() => {
- const [formStore, setFormStore] = createStore({
- value: "",
- error: undefined as string | undefined,
- })
-
- async function handleSubmit(e: SubmitEvent) {
- e.preventDefault()
-
- const form = e.currentTarget as HTMLFormElement
- const formData = new FormData(form)
- const apiKey = formData.get("apiKey") as string
-
- if (!apiKey?.trim()) {
- setFormStore("error", "API key is required")
- return
- }
-
- setFormStore("error", undefined)
- await globalSDK.client.auth.set({
- providerID: providerID(),
- auth: {
- type: "api",
- key: apiKey,
- },
- })
- await complete()
- }
-
- return (
- <div class="flex flex-col gap-6">
- <Switch>
- <Match when={provider().id === "opencode"}>
- <div class="flex flex-col gap-4">
- <div class="text-14-regular text-text-base">
- OpenCode Zen gives you access to a curated set of reliable optimized models for
- coding agents.
- </div>
- <div class="text-14-regular text-text-base">
- With a single API key you’ll get access to models such as Claude, GPT, Gemini,
- GLM and more.
- </div>
- <div class="text-14-regular text-text-base">
- Visit{" "}
- <Link href="https://opencode.ai/zen" tabIndex={-1}>
- opencode.ai/zen
- </Link>{" "}
- to collect your API key.
- </div>
- </div>
- </Match>
- <Match when={true}>
- <div class="text-14-regular text-text-base">
- Enter your {provider().name} API key to connect your account and use{" "}
- {provider().name} models in OpenCode.
- </div>
- </Match>
- </Switch>
- <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
- <TextField
- autofocus
- type="text"
- label={`${provider().name} API key`}
- placeholder="API key"
- name="apiKey"
- value={formStore.value}
- onChange={setFormStore.bind(null, "value")}
- validationState={formStore.error ? "invalid" : undefined}
- error={formStore.error}
- />
- <Button class="w-auto" type="submit" size="large" variant="primary">
- Submit
- </Button>
- </form>
- </div>
- )
- })}
- </Match>
- <Match when={store.method?.type === "oauth"}>
- <Switch>
- <Match when={store.authorization?.method === "code"}>
- {iife(() => {
- const [formStore, setFormStore] = createStore({
- value: "",
- error: undefined as string | undefined,
- })
-
- onMount(() => {
- if (store.authorization?.method === "code" && store.authorization?.url) {
- platform.openLink(store.authorization.url)
- }
- })
-
- async function handleSubmit(e: SubmitEvent) {
- e.preventDefault()
-
- const form = e.currentTarget as HTMLFormElement
- const formData = new FormData(form)
- const code = formData.get("code") as string
-
- if (!code?.trim()) {
- setFormStore("error", "Authorization code is required")
- return
- }
-
- setFormStore("error", undefined)
- const { error } = await globalSDK.client.provider.oauth.callback({
- providerID: providerID(),
- method: methodIndex(),
- code,
- })
- if (!error) {
- await complete()
- return
- }
- setFormStore("error", "Invalid authorization code")
- }
-
- return (
- <div class="flex flex-col gap-6">
- <div class="text-14-regular text-text-base">
- Visit <Link href={store.authorization!.url}>this link</Link> to collect your
- authorization code to connect your account and use {provider().name} models in
- OpenCode.
- </div>
- <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
- <TextField
- autofocus
- type="text"
- label={`${store.method?.label} authorization code`}
- placeholder="Authorization code"
- name="code"
- value={formStore.value}
- onChange={setFormStore.bind(null, "value")}
- validationState={formStore.error ? "invalid" : undefined}
- error={formStore.error}
- />
- <Button class="w-auto" type="submit" size="large" variant="primary">
- Submit
- </Button>
- </form>
- </div>
- )
- })}
- </Match>
- <Match when={store.authorization?.method === "auto"}>
- {iife(() => {
- const code = createMemo(() => {
- const instructions = store.authorization?.instructions
- if (instructions?.includes(":")) {
- return instructions?.split(":")[1]?.trim()
- }
- return instructions
- })
-
- onMount(async () => {
- const result = await globalSDK.client.provider.oauth.callback({
- providerID: providerID(),
- method: methodIndex(),
- })
- if (result.error) {
- // TODO: show error
- layout.dialog.close("connect")
- return
- }
- await complete()
- })
-
- return (
- <div class="flex flex-col gap-6">
- <div class="text-14-regular text-text-base">
- Visit <Link href={store.authorization!.url}>this link</Link> and enter the code
- below to connect your account and use {provider().name} models in OpenCode.
- </div>
- <TextField
- label="Confirmation code"
- class="font-mono"
- value={code()}
- readOnly
- copyable
- />
- <div class="text-14-regular text-text-base flex items-center gap-4">
- <Spinner />
- <span>Waiting for authorization...</span>
- </div>
- </div>
- )
- })}
- </Match>
- </Switch>
- </Match>
- </Switch>
- </div>
- </div>
- </Dialog.Body>
- </Dialog>
- )
- })}
+ <DialogConnect />
</Show>
</div>
<Toast.Region />
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index d4755af17..b8d4dadbd 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -392,6 +392,7 @@ export namespace Provider {
status: z.enum(["alpha", "beta", "deprecated", "active"]),
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()),
+ release_date: z.string(),
})
.meta({
ref: "Model",
@@ -470,6 +471,7 @@ export namespace Provider {
},
interleaved: model.interleaved ?? false,
},
+ release_date: model.release_date,
}
}
@@ -602,6 +604,8 @@ export namespace Provider {
output: model.limit?.output ?? existingModel?.limit?.output ?? 0,
},
headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
+ family: model.family ?? existingModel?.family ?? "",
+ release_date: model.release_date ?? existingModel?.release_date ?? "",
}
parsed.models[modelID] = parsedModel
}
diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx
index 06953168c..68707536a 100644
--- a/packages/ui/src/components/select-dialog.tsx
+++ b/packages/ui/src/components/select-dialog.tsx
@@ -1,4 +1,4 @@
-import { createEffect, Show, type JSX, splitProps, createSignal } from "solid-js"
+import { Show, type JSX, splitProps, createSignal } from "solid-js"
import { Dialog, DialogProps } from "./dialog"
import { Icon } from "./icon"
import { IconButton } from "./icon-button"
@@ -20,15 +20,6 @@ export function SelectDialog<T>(props: SelectDialogProps<T>) {
const [filter, setFilter] = createSignal("")
let listRef: ListRef | undefined
- createEffect(() => {
- if (!props.current) return
- const key = props.key(props.current)
- requestAnimationFrame(() => {
- const element = document.querySelector(`[data-key="${key}"]`)
- element?.scrollIntoView({ block: "center" })
- })
- })
-
const handleSelect = (item: T | undefined, index: number) => {
others.onSelect?.(item, index)
closeButton.click()
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index b50e4c8a0..a4a762fc6 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -50,19 +50,16 @@ export function SessionTurn(
let scrollRef: HTMLDivElement | undefined
const [state, setState] = createStore({
- contentRef: undefined as HTMLDivElement | undefined,
stickyTitleRef: undefined as HTMLDivElement | undefined,
stickyTriggerRef: undefined as HTMLDivElement | undefined,
userScrolled: false,
stickyHeaderHeight: 0,
scrollY: 0,
- autoScrolling: false,
})
function handleScroll() {
if (!scrollRef) return
setState("scrollY", scrollRef.scrollTop)
- if (state.autoScrolling) return
const { scrollTop, scrollHeight, clientHeight } = scrollRef
const atBottom = scrollHeight - scrollTop - clientHeight < 50
if (!atBottom && working()) {
@@ -77,13 +74,9 @@ export function SessionTurn(
}
function scrollToBottom() {
- if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return
- setState("autoScrolling", true)
+ if (!scrollRef || state.userScrolled || !working()) return
requestAnimationFrame(() => {
scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "auto" })
- requestAnimationFrame(() => {
- setState("autoScrolling", false)
- })
})
}
@@ -94,13 +87,6 @@ export function SessionTurn(
})
createResizeObserver(
- () => state.contentRef,
- () => {
- scrollToBottom()
- },
- )
-
- createResizeObserver(
() => state.stickyTitleRef,
({ height }) => {
const triggerHeight = state.stickyTriggerRef?.offsetHeight ?? 0
@@ -119,7 +105,7 @@ export function SessionTurn(
return (
<div data-component="session-turn" class={props.classes?.root} style={{ "--scroll-y": `${state.scrollY}px` }}>
<div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}>
- <div ref={(el) => setState("contentRef", el)} onClick={handleInteraction}>
+ <div onClick={handleInteraction}>
<Show when={message()}>
{(message) => {
const assistantMessages = createMemo(() => {
@@ -221,6 +207,11 @@ export function SessionTurn(
})
}
+ createEffect(() => {
+ lastPart()
+ scrollToBottom()
+ })
+
const [store, setStore] = createStore({
status: rawStatus(),
stepsExpanded: true,