Adam 2 месяцев назад
Родитель
Сommit
4a8e8f537c

+ 406 - 0
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>
+  )
+}

+ 212 - 0
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>
+  )
+}

+ 68 - 0
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>
+  )
+}

+ 3 - 205
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

+ 2 - 7
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) {

+ 73 - 20
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")
+        },
       }
     })()
 

+ 8 - 472
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 />

+ 4 - 0
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
       }

+ 1 - 10
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()

+ 7 - 16
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)
-      })
     })
   }
 
@@ -93,13 +86,6 @@ export function SessionTurn(
     }
   })
 
-  createResizeObserver(
-    () => state.contentRef,
-    () => {
-      scrollToBottom()
-    },
-  )
-
   createResizeObserver(
     () => state.stickyTitleRef,
     ({ height }) => {
@@ -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,