Просмотр исходного кода

refactor(app): unify provider connect dialog (#35518)

Brendan Allan 1 месяц назад
Родитель
Сommit
38bb38ecb2

+ 208 - 73
packages/app/src/components/dialog-connect-provider.tsx

@@ -7,20 +7,167 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { List, type ListRef } from "@opencode-ai/ui/list"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Spinner } from "@opencode-ai/ui/spinner"
+import { Tag } from "@opencode-ai/ui/tag"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast } from "@/utils/toast"
-import { type Accessor, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
+import {
+  type Accessor,
+  type Component,
+  createEffect,
+  createMemo,
+  createResource,
+  Match,
+  onCleanup,
+  onMount,
+  Show,
+  Switch,
+} from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { Link } from "@/components/link"
 import { useServerSDK } from "@/context/server-sdk"
 import { useServerSync } from "@/context/server-sync"
 import { useLanguage } from "@/context/language"
-import { useProviders } from "@/hooks/use-providers"
+import { popularProviders, useProviders } from "@/hooks/use-providers"
+import { CustomProviderForm } from "./dialog-custom-provider"
 
-export function DialogConnectProvider(props: {
+const CUSTOM_ID = "_custom"
+
+export function useProviderConnectController(options: { onBack?: () => void } = {}) {
+  const [store, setStore] = createStore({ selected: undefined as string | undefined })
+  const reset = () => setStore("selected", undefined)
+
+  return {
+    selected: () => store.selected,
+    select: (provider?: string) => setStore("selected", provider),
+    back: options.onBack ?? reset,
+  }
+}
+
+export const DialogConnectProvider: Component<{
+  directory?: Accessor<string | undefined>
+  controller?: ReturnType<typeof useProviderConnectController>
+}> = (props) => {
+  const fallback = useProviderConnectController()
+  const controller = props.controller ?? fallback
+  const language = useLanguage()
+  const reset = controller.back
+  const back = { current: reset }
+  const select = (provider?: string) => {
+    back.current = reset
+    controller.select(provider)
+  }
+
+  return (
+    <Dialog
+      class="h-full"
+      transition
+      title={
+        <Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
+          <IconButton
+            tabIndex={-1}
+            icon="arrow-left"
+            variant="ghost"
+            onClick={() => back.current()}
+            aria-label={language.t("common.goBack")}
+          />
+        </Show>
+      }
+    >
+      <Switch>
+        <Match when={controller.selected() === CUSTOM_ID}>
+          <CustomProviderForm />
+        </Match>
+        <Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
+          {(provider) => (
+            <ProviderConnection
+              provider={provider()}
+              directory={props.directory}
+              onBack={reset}
+              setBack={(handler) => (back.current = handler)}
+            />
+          )}
+        </Match>
+        <Match when={true}>
+          <ProviderPicker directory={props.directory} onSelect={select} />
+        </Match>
+      </Switch>
+    </Dialog>
+  )
+}
+
+function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSelect: (provider: string) => void }) {
+  const providers = useProviders(props.directory)
+  const language = useLanguage()
+  const popularGroup = () => language.t("dialog.provider.group.popular")
+  const otherGroup = () => language.t("dialog.provider.group.other")
+  const customLabel = () => language.t("settings.providers.tag.custom")
+  const note = (id: string) => {
+    if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
+    if (id === "openai") return language.t("dialog.provider.openai.note")
+    if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
+    if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
+    return undefined
+  }
+
+  return (
+    <List
+      class="px-3"
+      search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
+      emptyMessage={language.t("dialog.provider.empty")}
+      activeIcon="plus-small"
+      key={(x) => x?.id}
+      items={() => {
+        language.locale()
+        return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
+      }}
+      filterKeys={["id", "name"]}
+      groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
+      sortBy={(a, b) => {
+        if (a.id === CUSTOM_ID) return -1
+        if (b.id === CUSTOM_ID) return 1
+        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) => {
+        const popular = popularGroup()
+        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
+        props.onSelect(x.id)
+      }}
+    >
+      {(i) => (
+        <div class="px-1.25 w-full flex items-center gap-x-3">
+          <ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
+          <span>{i.name}</span>
+          <Show when={i.id === "opencode"}>
+            <div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
+          </Show>
+          <Show when={i.id === CUSTOM_ID}>
+            <Tag>{language.t("settings.providers.tag.custom")}</Tag>
+          </Show>
+          <Show when={i.id === "opencode"}>
+            <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
+          </Show>
+          <Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
+          <Show when={i.id === "opencode-go"}>
+            <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
+          </Show>
+        </div>
+      )}
+    </List>
+  )
+}
+
+function ProviderConnection(props: {
   provider: string
   directory?: Accessor<string | undefined>
   onBack: () => void
+  setBack: (handler: () => void) => void
 }) {
   const dialog = useDialog()
   const serverSync = useServerSync()
@@ -369,6 +516,8 @@ export function DialogConnectProvider(props: {
     props.onBack()
   }
 
+  props.setBack(goBack)
+
   function MethodSelection() {
     return (
       <>
@@ -589,81 +738,67 @@ export function DialogConnectProvider(props: {
   }
 
   return (
-    <Dialog
-      class="h-full"
-      title={
-        <IconButton
-          tabIndex={-1}
-          icon="arrow-left"
-          variant="ghost"
-          onClick={goBack}
-          aria-label={language.t("common.goBack")}
-        />
-      }
-      transition
-    >
-      <div class="flex flex-col gap-6 px-2.5 pb-3">
-        <div class="px-2.5 flex gap-4 items-center">
-          <ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
-          <div class="text-16-medium text-text-strong">
-            <Switch>
-              <Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
-                {language.t("provider.connect.title.anthropicProMax")}
-              </Match>
-              <Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
-            </Switch>
-          </div>
+    <div class="flex flex-col gap-6 px-2.5 pb-3">
+      <div class="px-2.5 flex gap-4 items-center">
+        <ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
+        <div class="text-16-medium text-text-strong">
+          <Switch>
+            <Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
+              {language.t("provider.connect.title.anthropicProMax")}
+            </Match>
+            <Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
+          </Switch>
         </div>
-        <div class="px-2.5 pb-10 flex flex-col gap-6">
-          <div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
-            <Switch>
-              <Match when={loading()}>
-                <div class="text-14-regular text-text-base">
-                  <div class="flex items-center gap-x-2">
-                    <Spinner />
-                    <span>{language.t("provider.connect.status.inProgress")}</span>
-                  </div>
+      </div>
+      <div class="px-2.5 pb-10 flex flex-col gap-6">
+        <div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
+          <Switch>
+            <Match when={loading()}>
+              <div class="text-14-regular text-text-base">
+                <div class="flex items-center gap-x-2">
+                  <Spinner />
+                  <span>{language.t("provider.connect.status.inProgress")}</span>
                 </div>
-              </Match>
-              <Match when={store.methodIndex === undefined}>
-                <MethodSelection />
-              </Match>
-              <Match when={store.state === "pending"}>
-                <div class="text-14-regular text-text-base">
-                  <div class="flex items-center gap-x-2">
-                    <Spinner />
-                    <span>{language.t("provider.connect.status.inProgress")}</span>
-                  </div>
+              </div>
+            </Match>
+            <Match when={store.methodIndex === undefined}>
+              <MethodSelection />
+            </Match>
+            <Match when={store.state === "pending"}>
+              <div class="text-14-regular text-text-base">
+                <div class="flex items-center gap-x-2">
+                  <Spinner />
+                  <span>{language.t("provider.connect.status.inProgress")}</span>
                 </div>
-              </Match>
-              <Match when={store.state === "prompt"}>
-                <AuthPromptsView />
-              </Match>
-              <Match when={store.state === "error"}>
-                <div class="text-14-regular text-text-base">
-                  <div class="flex items-center gap-x-2">
-                    <Icon name="circle-ban-sign" class="text-icon-critical-base" />
-                    <span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
-                  </div>
+              </div>
+            </Match>
+            <Match when={store.state === "prompt"}>
+              <AuthPromptsView />
+            </Match>
+            <Match when={store.state === "error"}>
+              <div class="text-14-regular text-text-base">
+                <div class="flex items-center gap-x-2">
+                  <Icon name="circle-ban-sign" class="text-icon-critical-base" />
+                  <span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
                 </div>
-              </Match>
-              <Match when={method()?.type === "api"}>
-                <ApiAuthView />
-              </Match>
-              <Match when={method()?.type === "oauth"}>
-                <Switch>
-                  <Match when={store.authorization?.method === "code"}>
-                    <OAuthCodeView />
-                  </Match>
-                  <Match when={store.authorization?.method === "auto"}>
-                    <OAuthAutoView />
-                  </Match>
-                </Switch>
-              </Match>
-            </Switch>
-          </div>
+              </div>
+            </Match>
+            <Match when={method()?.type === "api"}>
+              <ApiAuthView />
+            </Match>
+            <Match when={method()?.type === "oauth"}>
+              <Switch>
+                <Match when={store.authorization?.method === "code"}>
+                  <OAuthCodeView />
+                </Match>
+                <Match when={store.authorization?.method === "auto"}>
+                  <OAuthAutoView />
+                </Match>
+              </Switch>
+            </Match>
+          </Switch>
         </div>
       </div>
-    </Dialog>
+    </div>
   )
 }

+ 163 - 155
packages/app/src/components/dialog-custom-provider.tsx

@@ -19,6 +19,28 @@ type Props = {
 }
 
 export function DialogCustomProvider(props: Props) {
+  const language = useLanguage()
+
+  return (
+    <Dialog
+      class="h-full"
+      title={
+        <IconButton
+          tabIndex={-1}
+          icon="arrow-left"
+          variant="ghost"
+          onClick={props.onBack}
+          aria-label={language.t("common.goBack")}
+        />
+      }
+      transition
+    >
+      <CustomProviderForm />
+    </Dialog>
+  )
+}
+
+export function CustomProviderForm() {
   const dialog = useDialog()
   const serverSync = useServerSync()
   const serverSDK = useServerSDK()
@@ -153,169 +175,155 @@ export function DialogCustomProvider(props: Props) {
   }
 
   return (
-    <Dialog
-      class="h-full"
-      title={
-        <IconButton
-          tabIndex={-1}
-          icon="arrow-left"
-          variant="ghost"
-          onClick={props.onBack}
-          aria-label={language.t("common.goBack")}
-        />
-      }
-      transition
-    >
-      <div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
-        <div class="px-2.5 flex gap-4 items-center">
-          <ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
-          <div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
-        </div>
+    <div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
+      <div class="px-2.5 flex gap-4 items-center">
+        <ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
+        <div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
+      </div>
 
-        <form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
-          <p class="text-14-regular text-text-base">
-            {language.t("provider.custom.description.prefix")}
-            <Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
-              {language.t("provider.custom.description.link")}
-            </Link>
-            {language.t("provider.custom.description.suffix")}
-          </p>
+      <form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
+        <p class="text-14-regular text-text-base">
+          {language.t("provider.custom.description.prefix")}
+          <Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
+            {language.t("provider.custom.description.link")}
+          </Link>
+          {language.t("provider.custom.description.suffix")}
+        </p>
 
-          <div class="flex flex-col gap-4">
-            <TextField
-              autofocus
-              label={language.t("provider.custom.field.providerID.label")}
-              placeholder={language.t("provider.custom.field.providerID.placeholder")}
-              description={language.t("provider.custom.field.providerID.description")}
-              value={form.providerID}
-              onChange={(v) => setField("providerID", v)}
-              validationState={form.err.providerID ? "invalid" : undefined}
-              error={form.err.providerID}
-            />
-            <TextField
-              label={language.t("provider.custom.field.name.label")}
-              placeholder={language.t("provider.custom.field.name.placeholder")}
-              value={form.name}
-              onChange={(v) => setField("name", v)}
-              validationState={form.err.name ? "invalid" : undefined}
-              error={form.err.name}
-            />
-            <TextField
-              label={language.t("provider.custom.field.baseURL.label")}
-              placeholder={language.t("provider.custom.field.baseURL.placeholder")}
-              value={form.baseURL}
-              onChange={(v) => setField("baseURL", v)}
-              validationState={form.err.baseURL ? "invalid" : undefined}
-              error={form.err.baseURL}
-            />
-            <TextField
-              label={language.t("provider.custom.field.apiKey.label")}
-              placeholder={language.t("provider.custom.field.apiKey.placeholder")}
-              description={language.t("provider.custom.field.apiKey.description")}
-              value={form.apiKey}
-              onChange={(v) => setField("apiKey", v)}
-            />
-          </div>
+        <div class="flex flex-col gap-4">
+          <TextField
+            autofocus
+            label={language.t("provider.custom.field.providerID.label")}
+            placeholder={language.t("provider.custom.field.providerID.placeholder")}
+            description={language.t("provider.custom.field.providerID.description")}
+            value={form.providerID}
+            onChange={(v) => setField("providerID", v)}
+            validationState={form.err.providerID ? "invalid" : undefined}
+            error={form.err.providerID}
+          />
+          <TextField
+            label={language.t("provider.custom.field.name.label")}
+            placeholder={language.t("provider.custom.field.name.placeholder")}
+            value={form.name}
+            onChange={(v) => setField("name", v)}
+            validationState={form.err.name ? "invalid" : undefined}
+            error={form.err.name}
+          />
+          <TextField
+            label={language.t("provider.custom.field.baseURL.label")}
+            placeholder={language.t("provider.custom.field.baseURL.placeholder")}
+            value={form.baseURL}
+            onChange={(v) => setField("baseURL", v)}
+            validationState={form.err.baseURL ? "invalid" : undefined}
+            error={form.err.baseURL}
+          />
+          <TextField
+            label={language.t("provider.custom.field.apiKey.label")}
+            placeholder={language.t("provider.custom.field.apiKey.placeholder")}
+            description={language.t("provider.custom.field.apiKey.description")}
+            value={form.apiKey}
+            onChange={(v) => setField("apiKey", v)}
+          />
+        </div>
 
-          <div class="flex flex-col gap-3">
-            <label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
-            <For each={form.models}>
-              {(m, i) => (
-                <div class="flex gap-2 items-start" data-row={m.row}>
-                  <div class="flex-1">
-                    <TextField
-                      label={language.t("provider.custom.models.id.label")}
-                      hideLabel
-                      placeholder={language.t("provider.custom.models.id.placeholder")}
-                      value={m.id}
-                      onChange={(v) => setModel(i(), "id", v)}
-                      validationState={m.err.id ? "invalid" : undefined}
-                      error={m.err.id}
-                    />
-                  </div>
-                  <div class="flex-1">
-                    <TextField
-                      label={language.t("provider.custom.models.name.label")}
-                      hideLabel
-                      placeholder={language.t("provider.custom.models.name.placeholder")}
-                      value={m.name}
-                      onChange={(v) => setModel(i(), "name", v)}
-                      validationState={m.err.name ? "invalid" : undefined}
-                      error={m.err.name}
-                    />
-                  </div>
-                  <IconButton
-                    type="button"
-                    icon="trash"
-                    variant="ghost"
-                    class="mt-1.5"
-                    onClick={() => removeModel(i())}
-                    disabled={form.models.length <= 1}
-                    aria-label={language.t("provider.custom.models.remove")}
+        <div class="flex flex-col gap-3">
+          <label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
+          <For each={form.models}>
+            {(m, i) => (
+              <div class="flex gap-2 items-start" data-row={m.row}>
+                <div class="flex-1">
+                  <TextField
+                    label={language.t("provider.custom.models.id.label")}
+                    hideLabel
+                    placeholder={language.t("provider.custom.models.id.placeholder")}
+                    value={m.id}
+                    onChange={(v) => setModel(i(), "id", v)}
+                    validationState={m.err.id ? "invalid" : undefined}
+                    error={m.err.id}
                   />
                 </div>
-              )}
-            </For>
-            <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
-              {language.t("provider.custom.models.add")}
-            </Button>
-          </div>
-
-          <div class="flex flex-col gap-3">
-            <label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
-            <For each={form.headers}>
-              {(h, i) => (
-                <div class="flex gap-2 items-start" data-row={h.row}>
-                  <div class="flex-1">
-                    <TextField
-                      label={language.t("provider.custom.headers.key.label")}
-                      hideLabel
-                      placeholder={language.t("provider.custom.headers.key.placeholder")}
-                      value={h.key}
-                      onChange={(v) => setHeader(i(), "key", v)}
-                      validationState={h.err.key ? "invalid" : undefined}
-                      error={h.err.key}
-                    />
-                  </div>
-                  <div class="flex-1">
-                    <TextField
-                      label={language.t("provider.custom.headers.value.label")}
-                      hideLabel
-                      placeholder={language.t("provider.custom.headers.value.placeholder")}
-                      value={h.value}
-                      onChange={(v) => setHeader(i(), "value", v)}
-                      validationState={h.err.value ? "invalid" : undefined}
-                      error={h.err.value}
-                    />
-                  </div>
-                  <IconButton
-                    type="button"
-                    icon="trash"
-                    variant="ghost"
-                    class="mt-1.5"
-                    onClick={() => removeHeader(i())}
-                    disabled={form.headers.length <= 1}
-                    aria-label={language.t("provider.custom.headers.remove")}
+                <div class="flex-1">
+                  <TextField
+                    label={language.t("provider.custom.models.name.label")}
+                    hideLabel
+                    placeholder={language.t("provider.custom.models.name.placeholder")}
+                    value={m.name}
+                    onChange={(v) => setModel(i(), "name", v)}
+                    validationState={m.err.name ? "invalid" : undefined}
+                    error={m.err.name}
                   />
                 </div>
-              )}
-            </For>
-            <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
-              {language.t("provider.custom.headers.add")}
-            </Button>
-          </div>
+                <IconButton
+                  type="button"
+                  icon="trash"
+                  variant="ghost"
+                  class="mt-1.5"
+                  onClick={() => removeModel(i())}
+                  disabled={form.models.length <= 1}
+                  aria-label={language.t("provider.custom.models.remove")}
+                />
+              </div>
+            )}
+          </For>
+          <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
+            {language.t("provider.custom.models.add")}
+          </Button>
+        </div>
 
-          <Button
-            class="w-auto self-start"
-            type="submit"
-            size="large"
-            variant="primary"
-            disabled={saveMutation.isPending}
-          >
-            {saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
+        <div class="flex flex-col gap-3">
+          <label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
+          <For each={form.headers}>
+            {(h, i) => (
+              <div class="flex gap-2 items-start" data-row={h.row}>
+                <div class="flex-1">
+                  <TextField
+                    label={language.t("provider.custom.headers.key.label")}
+                    hideLabel
+                    placeholder={language.t("provider.custom.headers.key.placeholder")}
+                    value={h.key}
+                    onChange={(v) => setHeader(i(), "key", v)}
+                    validationState={h.err.key ? "invalid" : undefined}
+                    error={h.err.key}
+                  />
+                </div>
+                <div class="flex-1">
+                  <TextField
+                    label={language.t("provider.custom.headers.value.label")}
+                    hideLabel
+                    placeholder={language.t("provider.custom.headers.value.placeholder")}
+                    value={h.value}
+                    onChange={(v) => setHeader(i(), "value", v)}
+                    validationState={h.err.value ? "invalid" : undefined}
+                    error={h.err.value}
+                  />
+                </div>
+                <IconButton
+                  type="button"
+                  icon="trash"
+                  variant="ghost"
+                  class="mt-1.5"
+                  onClick={() => removeHeader(i())}
+                  disabled={form.headers.length <= 1}
+                  aria-label={language.t("provider.custom.headers.remove")}
+                />
+              </div>
+            )}
+          </For>
+          <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
+            {language.t("provider.custom.headers.add")}
           </Button>
-        </form>
-      </div>
-    </Dialog>
+        </div>
+
+        <Button
+          class="w-auto self-start"
+          type="submit"
+          size="large"
+          variant="primary"
+          disabled={saveMutation.isPending}
+        >
+          {saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
+        </Button>
+      </form>
+    </div>
   )
 }

+ 3 - 3
packages/app/src/components/dialog-manage-models.tsx

@@ -16,7 +16,7 @@ import { useLocal } from "@/context/local"
 import { popularProviders } from "@/hooks/use-providers"
 import { useLanguage } from "@/context/language"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { DialogSelectProvider } from "./dialog-select-provider"
+import { DialogConnectProvider } from "./dialog-connect-provider"
 import { decode64 } from "@/utils/base64"
 import { SettingsListV2 } from "./settings-v2/parts/list"
 import { SettingsRowV2 } from "./settings-v2/parts/row"
@@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => {
   const directory = () => decode64(local.slug())
 
   const handleConnectProvider = () => {
-    dialog.show(() => <DialogSelectProvider directory={directory} />)
+    void dialog.show(() => <DialogConnectProvider directory={directory} />)
   }
   const providerRank = (id: string) => popularProviders.indexOf(id)
   const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
@@ -123,7 +123,7 @@ export const DialogManageModelsV2: Component = () => {
   const directory = () => decode64(local.slug())
 
   const handleConnectProvider = () => {
-    dialog.show(() => <DialogSelectProvider directory={directory} />)
+    void dialog.show(() => <DialogConnectProvider directory={directory} />)
   }
   const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
   const providerVisible = (providerID: string) =>

+ 6 - 7
packages/app/src/components/dialog-select-model-unpaid.tsx

@@ -22,17 +22,16 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
   const providers = useProviders(directory)
   const language = useLanguage()
 
-  const connect = (provider: string) => {
+  const openProviders = (provider?: string) => {
     void import("./dialog-connect-provider").then((x) => {
-      dialog.show(() => <x.DialogConnectProvider provider={provider} directory={directory} onBack={all} />)
+      const controller = x.useProviderConnectController()
+      controller.select(provider)
+      void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
     })
   }
 
-  const all = () => {
-    void import("./dialog-select-provider").then((x) => {
-      dialog.show(() => <x.DialogSelectProvider directory={directory} />)
-    })
-  }
+  const connect = (provider: string) => openProviders(provider)
+  const all = () => openProviders()
 
   let listRef: ListRef | undefined
   const handleKeyDown = (e: KeyboardEvent) => {

+ 4 - 4
packages/app/src/components/dialog-select-model.tsx

@@ -155,8 +155,8 @@ export function ModelSelectorPopover(props: {
 
   const handleConnectProvider = () => {
     close("provider")
-    void import("./dialog-select-provider").then((x) => {
-      dialog.show(() => <x.DialogSelectProvider directory={directory} />)
+    void import("./dialog-connect-provider").then((x) => {
+      void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
     })
   }
   const language = useLanguage()
@@ -503,8 +503,8 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
   const directory = () => decode64(local.slug())
 
   const provider = () => {
-    void import("./dialog-select-provider").then((x) => {
-      dialog.show(() => <x.DialogSelectProvider directory={directory} />)
+    void import("./dialog-connect-provider").then((x) => {
+      void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
     })
   }
 

+ 0 - 97
packages/app/src/components/dialog-select-provider.tsx

@@ -1,97 +0,0 @@
-import { type Accessor, Component, Match, Show, Switch } from "solid-js"
-import { createStore } from "solid-js/store"
-import { popularProviders, useProviders } from "@/hooks/use-providers"
-import { Dialog } from "@opencode-ai/ui/dialog"
-import { List } from "@opencode-ai/ui/list"
-import { Tag } from "@opencode-ai/ui/tag"
-import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import { DialogConnectProvider } from "./dialog-connect-provider"
-import { useLanguage } from "@/context/language"
-import { DialogCustomProvider } from "./dialog-custom-provider"
-
-const CUSTOM_ID = "_custom"
-
-export const DialogSelectProvider: Component<{ directory?: Accessor<string | undefined> }> = (props) => {
-  const providers = useProviders(props.directory)
-  const language = useLanguage()
-  const [store, setStore] = createStore({ selected: undefined as string | undefined })
-
-  function showPicker() {
-    setStore("selected", undefined)
-  }
-
-  const popularGroup = () => language.t("dialog.provider.group.popular")
-  const otherGroup = () => language.t("dialog.provider.group.other")
-  const customLabel = () => language.t("settings.providers.tag.custom")
-  const note = (id: string) => {
-    if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
-    if (id === "openai") return language.t("dialog.provider.openai.note")
-    if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
-    if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
-  }
-
-  return (
-    <Switch>
-      <Match when={store.selected === CUSTOM_ID}>
-        <DialogCustomProvider onBack={showPicker} />
-      </Match>
-      <Match when={store.selected && store.selected !== CUSTOM_ID ? store.selected : undefined}>
-        {(provider) => <DialogConnectProvider provider={provider()} directory={props.directory} onBack={showPicker} />}
-      </Match>
-      <Match when={true}>
-        <Dialog class="h-full" transition title={language.t("command.provider.connect")}>
-          <List
-            class="px-3"
-            search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
-            emptyMessage={language.t("dialog.provider.empty")}
-            activeIcon="plus-small"
-            key={(x) => x?.id}
-            items={() => {
-              language.locale()
-              return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
-            }}
-            filterKeys={["id", "name"]}
-            groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
-            sortBy={(a, b) => {
-              if (a.id === CUSTOM_ID) return -1
-              if (b.id === CUSTOM_ID) return 1
-              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) => {
-              const popular = popularGroup()
-              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
-              setStore("selected", x.id)
-            }}
-          >
-            {(i) => (
-              <div class="px-1.25 w-full flex items-center gap-x-3">
-                <ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
-                <span>{i.name}</span>
-                <Show when={i.id === "opencode"}>
-                  <div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
-                </Show>
-                <Show when={i.id === CUSTOM_ID}>
-                  <Tag>{language.t("settings.providers.tag.custom")}</Tag>
-                </Show>
-                <Show when={i.id === "opencode"}>
-                  <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
-                </Show>
-                <Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
-                <Show when={i.id === "opencode-go"}>
-                  <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
-                </Show>
-              </div>
-            )}
-          </List>
-        </Dialog>
-      </Match>
-    </Switch>
-  )
-}

+ 14 - 3
packages/app/src/components/dialog-settings.tsx

@@ -4,19 +4,30 @@ import { Tabs } from "@opencode-ai/ui/tabs"
 import { Icon } from "@opencode-ai/ui/icon"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { SettingsGeneral } from "./settings-general"
 import { SettingsKeybinds } from "./settings-keybinds"
 import { SettingsProviders } from "./settings-providers"
 import { SettingsModels } from "./settings-models"
 import { SettingsServers } from "./settings-servers"
 
-export const DialogSettings: Component = () => {
+export const DialogSettings: Component<{ defaultValue?: string }> = (props) => {
   const language = useLanguage()
   const platform = usePlatform()
+  const dialog = useDialog()
+
+  const showProviders = () => {
+    void dialog.show(() => <DialogSettings defaultValue="providers" />)
+  }
 
   return (
     <Dialog size="x-large" transition>
-      <Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
+      <Tabs
+        orientation="vertical"
+        variant="settings"
+        defaultValue={props.defaultValue ?? "general"}
+        class="h-full settings-dialog"
+      >
         <Tabs.List>
           <div class="flex flex-col justify-between h-full w-full gap-4">
             <div class="flex flex-col gap-3 w-full pt-3">
@@ -70,7 +81,7 @@ export const DialogSettings: Component = () => {
           <SettingsServers />
         </Tabs.Content>
         <Tabs.Content value="providers" class="no-scrollbar">
-          <SettingsProviders />
+          <SettingsProviders onBack={showProviders} />
         </Tabs.Content>
         <Tabs.Content value="models" class="no-scrollbar">
           <SettingsModels />

+ 12 - 21
packages/app/src/components/settings-providers.tsx

@@ -8,8 +8,7 @@ import { createMemo, type Component, For, Show } from "solid-js"
 import { useLanguage } from "@/context/language"
 import { useServerSDK } from "@/context/server-sdk"
 import { useServerSync } from "@/context/server-sync"
-import { DialogConnectProvider } from "./dialog-connect-provider"
-import { DialogSelectProvider } from "./dialog-select-provider"
+import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
 import { DialogCustomProvider } from "./dialog-custom-provider"
 import { SettingsList } from "./settings-list"
 import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
@@ -28,20 +27,26 @@ const PROVIDER_NOTES = [
   { match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
 ] as const
 
-export const SettingsProviders: Component = () => {
+export const SettingsProviders: Component<{ onBack?: () => void }> = (props) => {
   return (
     <SettingsServerScope>
-      <SettingsProvidersContent />
+      <SettingsProvidersContent onBack={props.onBack} />
     </SettingsServerScope>
   )
 }
 
-const SettingsProvidersContent: Component = () => {
+const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => {
   const dialog = useDialog()
   const language = useLanguage()
   const serverSDK = useServerSDK()
   const serverSync = useServerSync()
   const providers = useProviders()
+  const providerConnect = useProviderConnectController({ onBack: props.onBack })
+
+  const connect = (provider?: string) => {
+    providerConnect.select(provider)
+    void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
+  }
 
   const connected = createMemo(() => {
     return providers
@@ -206,19 +211,7 @@ const SettingsProvidersContent: Component = () => {
                       {(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}
                     </Show>
                   </div>
-                  <Button
-                    size="large"
-                    variant="secondary"
-                    icon="plus-small"
-                    onClick={() => {
-                      dialog.show(() => (
-                        <DialogConnectProvider
-                          provider={item.id}
-                          onBack={() => dialog.show(() => <DialogSelectProvider />)}
-                        />
-                      ))
-                    }}
-                  >
+                  <Button size="large" variant="secondary" icon="plus-small" onClick={() => connect(item.id)}>
                     {language.t("common.connect")}
                   </Button>
                 </div>
@@ -255,9 +248,7 @@ const SettingsProvidersContent: Component = () => {
           <Button
             variant="ghost"
             class="px-0 py-0 mt-5 text-14-medium text-text-interactive-base text-left justify-start hover:bg-transparent active:bg-transparent"
-            onClick={() => {
-              dialog.show(() => <DialogSelectProvider />)
-            }}
+            onClick={() => connect()}
           >
             {language.t("dialog.provider.viewAll")}
           </Button>

+ 14 - 2
packages/app/src/components/settings-v2/dialog-settings-v2.tsx

@@ -10,16 +10,28 @@ import { SettingsProvidersV2 } from "./providers"
 import { SettingsModelsV2 } from "./models"
 import "./settings-v2.css"
 import { SettingsServersV2 } from "./servers"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
 
 export const DialogSettings: Component<{
   sessionID?: string
+  defaultValue?: string
 }> = (props) => {
   const language = useLanguage()
   const platform = usePlatform()
+  const dialog = useDialog()
+
+  const showProviders = () => {
+    void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
+  }
 
   return (
     <Dialog size="x-large" variant="settings" class="settings-v2-dialog">
-      <TabsV2 orientation="vertical" variant="settings" defaultValue="general" class="settings-v2">
+      <TabsV2
+        orientation="vertical"
+        variant="settings"
+        defaultValue={props.defaultValue ?? "general"}
+        class="settings-v2"
+      >
         <TabsV2.List>
           <div class="flex flex-col justify-between h-full w-full">
             <div class="flex flex-col gap-3 w-full">
@@ -73,7 +85,7 @@ export const DialogSettings: Component<{
           <SettingsServersV2 />
         </TabsV2.Content>
         <TabsV2.Content value="providers" class="settings-v2-panel">
-          <SettingsProvidersV2 />
+          <SettingsProvidersV2 onBack={showProviders} />
         </TabsV2.Content>
         <TabsV2.Content value="models" class="settings-v2-panel">
           <SettingsModelsV2 />

+ 10 - 23
packages/app/src/components/settings-v2/providers.tsx

@@ -8,8 +8,7 @@ import { createMemo, type Component, For, Show } from "solid-js"
 import { useLanguage } from "@/context/language"
 import { useServerSDK } from "@/context/server-sdk"
 import { useServerSync } from "@/context/server-sync"
-import { DialogConnectProvider } from "../dialog-connect-provider"
-import { DialogSelectProvider } from "../dialog-select-provider"
+import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
 import { DialogCustomProvider } from "../dialog-custom-provider"
 import { SettingsListV2 } from "./parts/list"
 import "./settings-v2.css"
@@ -30,12 +29,18 @@ const PROVIDER_NOTES = [
 
 const PROVIDER_ICON_SIZE = 16
 
-export const SettingsProvidersV2: Component = () => {
+export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) => {
   const dialog = useDialog()
   const language = useLanguage()
   const serverSdk = useServerSDK()
   const serverSync = useServerSync()
   const providers = useProviders()
+  const providerConnect = useProviderConnectController({ onBack: props.onBack })
+
+  const connect = (provider?: string) => {
+    providerConnect.select(provider)
+    void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
+  }
 
   const connected = createMemo(() => {
     return providers
@@ -206,19 +211,7 @@ export const SettingsProvidersV2: Component = () => {
                       </Show>
                     </div>
                   </div>
-                  <ButtonV2
-                    size="normal"
-                    variant="neutral"
-                    icon="plus"
-                    onClick={() => {
-                      dialog.show(() => (
-                        <DialogConnectProvider
-                          provider={item.id}
-                          onBack={() => dialog.show(() => <DialogSelectProvider />)}
-                        />
-                      ))
-                    }}
-                  >
+                  <ButtonV2 size="normal" variant="neutral" icon="plus" onClick={() => connect(item.id)}>
                     {language.t("common.connect")}
                   </ButtonV2>
                 </div>
@@ -254,13 +247,7 @@ export const SettingsProvidersV2: Component = () => {
             </div>
           </SettingsListV2>
 
-          <button
-            type="button"
-            class="settings-v2-providers-view-all"
-            onClick={() => {
-              dialog.show(() => <DialogSelectProvider />)
-            }}
-          >
+          <button type="button" class="settings-v2-providers-view-all" onClick={() => connect()}>
             {language.t("dialog.provider.viewAll")}
           </button>
         </div>

+ 2 - 2
packages/app/src/pages/layout.tsx

@@ -1095,9 +1095,9 @@ export default function LegacyLayout(props: ParentProps) {
 
   function connectProvider() {
     const run = ++dialogRun
-    void import("@/components/dialog-select-provider").then((x) => {
+    void import("@/components/dialog-connect-provider").then((x) => {
       if (dialogDead || dialogRun !== run) return
-      dialog.show(() => <x.DialogSelectProvider />)
+      void dialog.show(() => <x.DialogConnectProvider />)
     })
   }
 

+ 5 - 12
packages/app/src/pages/session/usage-exceeded-dialogs.tsx

@@ -76,18 +76,11 @@ export function useUsageExceededDialogs() {
               setGoUpsellState(keys.lastSeenAt, Date.now())
               if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now())
               else {
-                void import("../../components/dialog-connect-provider").then((x) =>
-                  dialog.show(() => (
-                    <x.DialogConnectProvider
-                      provider="opencode-go"
-                      onBack={() => {
-                        void import("../../components/dialog-select-provider").then((provider) => {
-                          dialog.show(() => <provider.DialogSelectProvider />)
-                        })
-                      }}
-                    />
-                  )),
-                )
+                void import("../../components/dialog-connect-provider").then((x) => {
+                  const controller = x.useProviderConnectController()
+                  controller.select("opencode-go")
+                  void dialog.show(() => <x.DialogConnectProvider controller={controller} />)
+                })
               }
             }}
           />