Explorar o código

feat(desktop): align provider onboarding dialogs (#36733)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
usrnk1 hai 1 mes
pai
achega
f7d5a1cbc7

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

@@ -0,0 +1,73 @@
+// @ts-nocheck
+import { Button } from "@opencode-ai/ui/button"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
+import { mockProviderAuth } from "@/context/server-sync"
+import { onCleanup, onMount } from "solid-js"
+import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
+
+function ConnectProviderDialogStory() {
+  const dialog = useDialog()
+  const open = () => dialog.show(() => <DialogConnectProvider />)
+
+  onMount(open)
+
+  return (
+    <Button variant="secondary" onClick={open}>
+      Open connect provider dialog
+    </Button>
+  )
+}
+
+function ProviderConnectionDialogStory(props) {
+  onCleanup(mockProviderAuth(props.provider, props.methods))
+  const dialog = useDialog()
+  const controller = useProviderConnectController()
+  controller.select(props.provider)
+  const open = () => dialog.show(() => <DialogConnectProvider controller={controller} />)
+
+  onMount(open)
+
+  return (
+    <Button variant="secondary" onClick={open}>
+      Open {props.provider} connection dialog
+    </Button>
+  )
+}
+
+function renderConnection(provider, methods) {
+  return () => (
+    <QueryClientProvider client={new QueryClient()}>
+      <ProviderConnectionDialogStory provider={provider} methods={methods} />
+    </QueryClientProvider>
+  )
+}
+
+export default {
+  title: "App/Dialogs/Connect Provider",
+  id: "app-dialog-connect-provider",
+}
+
+export const V2 = {
+  render: () => (
+    <QueryClientProvider client={new QueryClient()}>
+      <ConnectProviderDialogStory />
+    </QueryClientProvider>
+  ),
+}
+
+export const ApiKey = {
+  render: renderConnection("openrouter", [{ type: "api", label: "API key" }]),
+}
+
+export const OpenCodeZen = {
+  render: renderConnection("opencode", [{ type: "api", label: "API key" }]),
+}
+
+export const LoginMethods = {
+  render: renderConnection("openai", [
+    { type: "oauth", label: "ChatGPT Pro/Plus (browser)" },
+    { type: "oauth", label: "ChatGPT Pro/Plus (headless)" },
+    { type: "api", label: "API key" },
+  ]),
+}

+ 437 - 30
packages/app/src/components/dialog-connect-provider.tsx

@@ -9,6 +9,9 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Spinner } from "@opencode-ai/ui/spinner"
 import { Spinner } from "@opencode-ai/ui/spinner"
 import { Tag } from "@opencode-ai/ui/tag"
 import { Tag } from "@opencode-ai/ui/tag"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { TextField } from "@opencode-ai/ui/text-field"
+import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
+import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
 import { showToast } from "@/utils/toast"
 import { showToast } from "@/utils/toast"
 import {
 import {
   type Accessor,
   type Accessor,
@@ -16,6 +19,8 @@ import {
   createEffect,
   createEffect,
   createMemo,
   createMemo,
   createResource,
   createResource,
+  createUniqueId,
+  For,
   Match,
   Match,
   onCleanup,
   onCleanup,
   onMount,
   onMount,
@@ -27,6 +32,7 @@ import { Link } from "@/components/link"
 import { useServerSDK } from "@/context/server-sdk"
 import { useServerSDK } from "@/context/server-sdk"
 import { useServerSync } from "@/context/server-sync"
 import { useServerSync } from "@/context/server-sync"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
+import { useSettings } from "@/context/settings"
 import { popularProviders, useProviders } from "@/hooks/use-providers"
 import { popularProviders, useProviders } from "@/hooks/use-providers"
 import { CustomProviderForm } from "./dialog-custom-provider"
 import { CustomProviderForm } from "./dialog-custom-provider"
 
 
@@ -50,32 +56,22 @@ export const DialogConnectProvider: Component<{
   const fallback = useProviderConnectController()
   const fallback = useProviderConnectController()
   const controller = props.controller ?? fallback
   const controller = props.controller ?? fallback
   const language = useLanguage()
   const language = useLanguage()
+  const settings = useSettings()
+  const newLayout = settings.general.newLayoutDesigns
   const reset = controller.back
   const reset = controller.back
   const back = { current: reset }
   const back = { current: reset }
+  let focusHost: HTMLDivElement | undefined
+  const holdFocus = () => focusHost?.focus({ preventScroll: true })
   const select = (provider?: string) => {
   const select = (provider?: string) => {
     back.current = reset
     back.current = reset
     controller.select(provider)
     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>
-      }
-    >
+  function Content() {
+    return (
       <Switch>
       <Switch>
         <Match when={controller.selected() === CUSTOM_ID}>
         <Match when={controller.selected() === CUSTOM_ID}>
-          <CustomProviderForm />
+          <CustomProviderForm autofocus={!newLayout()} />
         </Match>
         </Match>
         <Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
         <Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
           {(provider) => (
           {(provider) => (
@@ -88,14 +84,76 @@ export const DialogConnectProvider: Component<{
           )}
           )}
         </Match>
         </Match>
         <Match when={true}>
         <Match when={true}>
-          <ProviderPicker directory={props.directory} onSelect={select} />
+          <ProviderPicker
+            directory={props.directory}
+            onSelect={select}
+            onPrepare={newLayout() ? holdFocus : undefined}
+          />
         </Match>
         </Match>
       </Switch>
       </Switch>
-    </Dialog>
+    )
+  }
+
+  return (
+    <Show
+      when={newLayout()}
+      fallback={
+        <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>
+          }
+        >
+          <Content />
+        </Dialog>
+      }
+    >
+      <DialogV2
+        containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
+        class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
+      >
+        <DialogHeader closeLabel={language.t("common.close")}>
+          <Show
+            when={controller.selected()}
+            fallback={<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>}
+          >
+            <button
+              type="button"
+              class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
+              onClick={() => back.current()}
+              aria-label={language.t("common.goBack")}
+            >
+              <Icon name="arrow-left" size="small" />
+            </button>
+          </Show>
+        </DialogHeader>
+        <DialogBody class="min-h-0 flex-1 overflow-hidden px-2 pb-2">
+          <div ref={focusHost} tabIndex={-1} class="flex min-h-0 flex-1 flex-col outline-none">
+            <Content />
+          </div>
+        </DialogBody>
+      </DialogV2>
+    </Show>
   )
   )
 }
 }
 
 
-function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSelect: (provider: string) => void }) {
+function ProviderPicker(props: {
+  directory?: Accessor<string | undefined>
+  onSelect: (provider: string) => void
+  onPrepare?: () => void
+}) {
+  const settings = useSettings()
+  if (settings.general.newLayoutDesigns())
+    return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
   const providers = useProviders(props.directory)
   const providers = useProviders(props.directory)
   const language = useLanguage()
   const language = useLanguage()
   const popularGroup = () => language.t("dialog.provider.group.popular")
   const popularGroup = () => language.t("dialog.provider.group.popular")
@@ -163,6 +221,171 @@ function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSel
   )
   )
 }
 }
 
 
+function ProviderPickerV2(props: {
+  directory?: Accessor<string | undefined>
+  onSelect: (provider: string) => void
+  onPrepare?: () => void
+}) {
+  const providers = useProviders(props.directory)
+  const language = useLanguage()
+  const serverSync = useServerSync()
+  const serverSDK = useServerSDK()
+  const [store, setStore] = createStore({
+    filter: "",
+    active: undefined as string | undefined,
+    connecting: undefined as string | undefined,
+  })
+  const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
+  const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
+  const all = createMemo(() => {
+    language.locale()
+    const query = store.filter.trim().toLowerCase()
+    const values = [custom(), ...providers.all().values()]
+    if (!query) return values
+    return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
+  })
+  const popular = createMemo(() =>
+    all()
+      .filter((provider) => featured.includes(provider.id))
+      .sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)),
+  )
+  const other = createMemo(() =>
+    all()
+      .filter((provider) => !featured.includes(provider.id))
+      .sort((a, b) => {
+        if (a.id === CUSTOM_ID) return -1
+        if (b.id === CUSTOM_ID) return 1
+        return a.name.localeCompare(b.name)
+      }),
+  )
+  const rows = createMemo(() => [...popular(), ...other()])
+  let picker: HTMLDivElement | undefined
+  let search: HTMLInputElement | undefined
+
+  onMount(() => search?.focus({ preventScroll: true }))
+
+  const connect = (provider: string) => {
+    props.onPrepare?.()
+    if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) {
+      props.onSelect(provider)
+      return
+    }
+    if (store.connecting) return
+    setStore("connecting", provider)
+    void serverSDK()
+      .client.provider.auth()
+      .then((response) => {
+        serverSync().set("provider_auth", response.data ?? {})
+        props.onSelect(provider)
+      })
+      .catch(() => props.onSelect(provider))
+  }
+
+  const move = (event: KeyboardEvent, direction: number) => {
+    const items = rows()
+    if (items.length === 0) return
+    const index = items.findIndex((provider) => provider.id === store.active)
+    const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
+    setStore("active", items[next].id)
+    picker
+      ?.querySelector<HTMLElement>(`[data-provider-id="${CSS.escape(items[next].id)}"]`)
+      ?.focus({ preventScroll: true })
+    event.preventDefault()
+  }
+
+  const handleKeyDown = (event: KeyboardEvent) => {
+    if (event.key === "ArrowDown") return move(event, 1)
+    if (event.key === "ArrowUp") return move(event, -1)
+    if (event.key !== "Enter" || !store.active) return
+    connect(store.active)
+    event.preventDefault()
+  }
+
+  return (
+    <div ref={picker} class="flex min-h-0 flex-1 flex-col gap-4" onKeyDown={handleKeyDown}>
+      <div class="shrink-0 px-1 pt-px">
+        <TextInputV2
+          ref={search}
+          type="search"
+          class="!w-full [font-family:var(--v2-font-family-sans)]"
+          leadingIcon={<Icon name="magnifying-glass" size="small" />}
+          placeholder={language.t("dialog.provider.search.placeholder")}
+          value={store.filter}
+          onInput={(event) => {
+            setStore({ filter: event.currentTarget.value, active: undefined })
+          }}
+        />
+      </div>
+      <div class="relative min-h-0 flex-1">
+        <div class="flex size-full min-h-0 flex-col gap-4 overflow-y-auto pb-8 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
+          <For
+            each={[
+              { title: language.t("dialog.provider.group.popular"), items: popular },
+              { title: language.t("dialog.provider.group.other"), items: other },
+            ]}
+          >
+            {(group) => (
+              <Show when={group.items().length > 0}>
+                <section class="flex flex-col">
+                  <div class="px-3 pb-2 text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-muted">
+                    {group.title}
+                  </div>
+                  <For each={group.items()}>
+                    {(provider) => (
+                      <button
+                        type="button"
+                        data-provider-id={provider.id}
+                        class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-none tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
+                        classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === provider.id }}
+                        onMouseEnter={() => setStore("active", provider.id)}
+                        disabled={store.connecting !== undefined}
+                        aria-busy={store.connecting === provider.id}
+                        onClick={() => connect(provider.id)}
+                      >
+                        <ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-base" />
+                        <span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
+                        <Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
+                          <span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
+                            {language.t(
+                              provider.id === "opencode"
+                                ? "dialog.provider.opencode.tagline"
+                                : "dialog.provider.opencodeGo.tagline",
+                            )}
+                          </span>
+                          <span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
+                            {language.t("dialog.provider.tag.recommended")}
+                          </span>
+                        </Show>
+                        <Show when={provider.id === CUSTOM_ID}>
+                          <span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
+                            {language.t("settings.providers.tag.custom")}
+                          </span>
+                        </Show>
+                        <Show when={store.connecting === provider.id}>
+                          <Spinner class="ml-auto size-4 shrink-0 text-v2-icon-icon-muted" />
+                        </Show>
+                      </button>
+                    )}
+                  </For>
+                </section>
+              </Show>
+            )}
+          </For>
+          <Show when={rows().length === 0}>
+            <div class="flex h-24 items-center justify-center text-[13px] font-[440] text-v2-text-text-muted">
+              {language.t("dialog.provider.empty")}
+            </div>
+          </Show>
+        </div>
+        <div
+          class="pointer-events-none absolute inset-x-0 bottom-0 h-10"
+          style={{ background: "linear-gradient(to bottom, transparent, var(--v2-background-bg-layer-01))" }}
+        />
+      </div>
+    </div>
+  )
+}
+
 function ProviderConnection(props: {
 function ProviderConnection(props: {
   provider: string
   provider: string
   directory?: Accessor<string | undefined>
   directory?: Accessor<string | undefined>
@@ -173,6 +396,8 @@ function ProviderConnection(props: {
   const serverSync = useServerSync()
   const serverSync = useServerSync()
   const serverSDK = useServerSDK()
   const serverSDK = useServerSDK()
   const language = useLanguage()
   const language = useLanguage()
+  const settings = useSettings()
+  const newLayout = settings.general.newLayoutDesigns
   const providers = useProviders(props.directory)
   const providers = useProviders(props.directory)
 
 
   const alive = { value: true }
   const alive = { value: true }
@@ -207,11 +432,19 @@ function ProviderConnection(props: {
   )
   )
   const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
   const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
   const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
   const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
+  const cachedMethods = serverSync().data.provider_auth[props.provider]
+  const directMethod =
+    cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined
   const [store, setStore] = createStore({
   const [store, setStore] = createStore({
-    methodIndex: undefined as undefined | number,
+    methodIndex: directMethod as undefined | number,
     authorization: undefined as undefined | ProviderAuthAuthorization,
     authorization: undefined as undefined | ProviderAuthAuthorization,
     promptInputs: undefined as undefined | Record<string, string>,
     promptInputs: undefined as undefined | Record<string, string>,
-    state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
+    state: (directMethod === undefined ? "pending" : undefined) as
+      | undefined
+      | "pending"
+      | "complete"
+      | "error"
+      | "prompt",
     error: undefined as string | undefined,
     error: undefined as string | undefined,
   })
   })
 
 
@@ -279,6 +512,16 @@ function ProviderConnection(props: {
     return value.label ?? ""
     return value.label ?? ""
   }
   }
 
 
+  const methodDetails = (value?: { type?: string; label?: string }) => {
+    const label = methodLabel(value)
+    const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i)
+    const hint = suffix?.[1]
+    return {
+      label: suffix ? label.slice(0, -suffix[0].length) : label,
+      hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined,
+    }
+  }
+
   function formatError(value: unknown, fallback: string): string {
   function formatError(value: unknown, fallback: string): string {
     if (value && typeof value === "object" && "data" in value) {
     if (value && typeof value === "object" && "data" in value) {
       const data = (value as { data?: { message?: unknown } }).data
       const data = (value as { data?: { message?: unknown } }).data
@@ -519,6 +762,37 @@ function ProviderConnection(props: {
   props.setBack(goBack)
   props.setBack(goBack)
 
 
   function MethodSelection() {
   function MethodSelection() {
+    if (newLayout())
+      return (
+        <div class="flex flex-col gap-2">
+          <div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
+            {language.t("provider.connect.selectMethod", { provider: provider().name })}
+          </div>
+          <div class="flex flex-col">
+            <For each={methods()}>
+              {(item, index) => {
+                const details = () => methodDetails(item)
+                return (
+                  <button
+                    type="button"
+                    class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
+                    onClick={() => void selectMethod(index())}
+                  >
+                    <span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
+                      <span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
+                    </span>
+                    <span class="font-[530] text-v2-text-text-base">{details().label}</span>
+                    <Show when={details().hint}>
+                      {(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
+                    </Show>
+                  </button>
+                )
+              }}
+            </For>
+          </div>
+        </div>
+      )
+
     return (
     return (
       <>
       <>
         <div class="text-14-regular text-text-base">
         <div class="text-14-regular text-text-base">
@@ -552,11 +826,18 @@ function ProviderConnection(props: {
   }
   }
 
 
   function ApiAuthView() {
   function ApiAuthView() {
+    let apiKey: HTMLInputElement | undefined
+    const errorID = createUniqueId()
     const [formStore, setFormStore] = createStore({
     const [formStore, setFormStore] = createStore({
       value: "",
       value: "",
       error: undefined as string | undefined,
       error: undefined as string | undefined,
     })
     })
 
 
+    onMount(() => {
+      if (!newLayout()) return
+      apiKey?.focus({ preventScroll: true })
+    })
+
     async function handleSubmit(e: SubmitEvent) {
     async function handleSubmit(e: SubmitEvent) {
       e.preventDefault()
       e.preventDefault()
 
 
@@ -581,6 +862,58 @@ function ProviderConnection(props: {
       await complete()
       await complete()
     }
     }
 
 
+    if (newLayout())
+      return (
+        <div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
+          <Show
+            when={provider().id === "opencode"}
+            fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
+          >
+            <div class="flex flex-col gap-5">
+              <div>{language.t("provider.connect.opencodeZen.line1")}</div>
+              <div>{language.t("provider.connect.opencodeZen.line2")}</div>
+              <div>
+                {language.t("provider.connect.opencodeZen.visit.prefix")}
+                <Link
+                  href="https://opencode.ai/zen"
+                  class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
+                >
+                  {language.t("provider.connect.opencodeZen.visit.link")}
+                </Link>
+                {language.t("provider.connect.opencodeZen.visit.suffix")}
+              </div>
+            </div>
+          </Show>
+          <form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
+            <label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
+              {language.t("provider.connect.apiKey.label", { provider: provider().name })}
+              <TextInputV2
+                ref={apiKey}
+                class="!w-full"
+                name="apiKey"
+                placeholder={language.t("provider.connect.apiKey.placeholder")}
+                value={formStore.value}
+                invalid={formStore.error !== undefined}
+                aria-describedby={formStore.error ? errorID : undefined}
+                autocomplete="off"
+                spellcheck={false}
+                onInput={(event) => setFormStore("value", event.currentTarget.value)}
+              />
+            </label>
+            <Show when={formStore.error}>
+              {(error) => (
+                <div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
+                  {error()}
+                </div>
+              )}
+            </Show>
+            <ButtonV2 type="submit" variant="contrast">
+              {language.t("common.continue")}
+            </ButtonV2>
+          </form>
+        </div>
+      )
+
     return (
     return (
       <div class="flex flex-col gap-6">
       <div class="flex flex-col gap-6">
         <Switch>
         <Switch>
@@ -605,7 +938,8 @@ function ProviderConnection(props: {
         </Switch>
         </Switch>
         <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
         <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
           <TextField
           <TextField
-            autofocus
+            autofocus={!newLayout()}
+            ref={apiKey}
             type="text"
             type="text"
             label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
             label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
             placeholder={language.t("provider.connect.apiKey.placeholder")}
             placeholder={language.t("provider.connect.apiKey.placeholder")}
@@ -624,11 +958,18 @@ function ProviderConnection(props: {
   }
   }
 
 
   function OAuthCodeView() {
   function OAuthCodeView() {
+    let codeInput: HTMLInputElement | undefined
+    const errorID = createUniqueId()
     const [formStore, setFormStore] = createStore({
     const [formStore, setFormStore] = createStore({
       value: "",
       value: "",
       error: undefined as string | undefined,
       error: undefined as string | undefined,
     })
     })
 
 
+    onMount(() => {
+      if (!newLayout()) return
+      codeInput?.focus({ preventScroll: true })
+    })
+
     async function handleSubmit(e: SubmitEvent) {
     async function handleSubmit(e: SubmitEvent) {
       e.preventDefault()
       e.preventDefault()
 
 
@@ -657,6 +998,46 @@ function ProviderConnection(props: {
       setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
       setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
     }
     }
 
 
+    if (newLayout())
+      return (
+        <div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
+          <div>
+            {language.t("provider.connect.oauth.code.visit.prefix")}
+            <Link href={store.authorization!.url} class="text-v2-text-text-base">
+              {language.t("provider.connect.oauth.code.visit.link")}
+            </Link>
+            {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
+          </div>
+          <form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
+            <label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
+              {language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
+              <TextInputV2
+                ref={codeInput}
+                class="!w-full"
+                name="code"
+                placeholder={language.t("provider.connect.oauth.code.placeholder")}
+                value={formStore.value}
+                invalid={formStore.error !== undefined}
+                aria-describedby={formStore.error ? errorID : undefined}
+                autocomplete="off"
+                spellcheck={false}
+                onInput={(event) => setFormStore("value", event.currentTarget.value)}
+              />
+            </label>
+            <Show when={formStore.error}>
+              {(error) => (
+                <div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
+                  {error()}
+                </div>
+              )}
+            </Show>
+            <ButtonV2 type="submit" variant="contrast">
+              {language.t("common.continue")}
+            </ButtonV2>
+          </form>
+        </div>
+      )
+
     return (
     return (
       <div class="flex flex-col gap-6">
       <div class="flex flex-col gap-6">
         <div class="text-14-regular text-text-base">
         <div class="text-14-regular text-text-base">
@@ -666,7 +1047,8 @@ function ProviderConnection(props: {
         </div>
         </div>
         <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
         <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
           <TextField
           <TextField
-            autofocus
+            autofocus={!newLayout()}
+            ref={codeInput}
             type="text"
             type="text"
             label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
             label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
             placeholder={language.t("provider.connect.oauth.code.placeholder")}
             placeholder={language.t("provider.connect.oauth.code.placeholder")}
@@ -738,10 +1120,27 @@ function ProviderConnection(props: {
   }
   }
 
 
   return (
   return (
-    <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">
+    <div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-3"}>
+      <div
+        class={
+          newLayout() ? "flex h-10 shrink-0 items-start gap-2 px-3" : "flex items-center gap-4 px-2.5"
+        }
+      >
+        <ProviderIcon
+          id={props.provider}
+          class={
+            newLayout()
+              ? "mt-0.5 size-4 shrink-0 text-v2-icon-icon-base"
+              : "size-5 shrink-0 icon-strong-base"
+          }
+        />
+        <div
+          class={
+            newLayout()
+              ? "text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base"
+              : "text-16-medium text-text-strong"
+          }
+        >
           <Switch>
           <Switch>
             <Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
             <Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
               {language.t("provider.connect.title.anthropicProMax")}
               {language.t("provider.connect.title.anthropicProMax")}
@@ -750,8 +1149,16 @@ function ProviderConnection(props: {
           </Switch>
           </Switch>
         </div>
         </div>
       </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}>
+      <div
+        class={
+          newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-10"
+        }
+      >
+        <div
+          onKeyDown={handleKey}
+          tabIndex={newLayout() ? undefined : 0}
+          autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
+        >
           <Switch>
           <Switch>
             <Match when={loading()}>
             <Match when={loading()}>
               <div class="text-14-regular text-text-base">
               <div class="text-14-regular text-text-base">

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

@@ -40,7 +40,7 @@ export function DialogCustomProvider(props: Props) {
   )
   )
 }
 }
 
 
-export function CustomProviderForm() {
+export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
   const dialog = useDialog()
   const dialog = useDialog()
   const serverSync = useServerSync()
   const serverSync = useServerSync()
   const serverSDK = useServerSDK()
   const serverSDK = useServerSDK()
@@ -192,7 +192,7 @@ export function CustomProviderForm() {
 
 
         <div class="flex flex-col gap-4">
         <div class="flex flex-col gap-4">
           <TextField
           <TextField
-            autofocus
+            autofocus={props.autofocus ?? true}
             label={language.t("provider.custom.field.providerID.label")}
             label={language.t("provider.custom.field.providerID.label")}
             placeholder={language.t("provider.custom.field.providerID.placeholder")}
             placeholder={language.t("provider.custom.field.providerID.placeholder")}
             description={language.t("provider.custom.field.providerID.description")}
             description={language.t("provider.custom.field.providerID.description")}

+ 55 - 0
packages/app/src/components/dialog-select-model-unpaid-v2.stories.tsx

@@ -0,0 +1,55 @@
+// @ts-nocheck
+import { Button } from "@opencode-ai/ui/button"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { createSignal, onMount } from "solid-js"
+import { DialogSelectModelUnpaidV2 } from "./dialog-select-model-unpaid-v2"
+
+const names = [
+  "MiMo V2.5 Free",
+  "Nemotron 3 Ultra Free",
+  "Deepseek V4 Flash Free",
+  "North Mini Code Free",
+  "Hy3 Free",
+  "Big Pickle",
+]
+
+function SelectModelWithoutProviders() {
+  const dialog = useDialog()
+  const models = names.map((name, index) => ({
+    id: name.toLowerCase().replaceAll(" ", "-"),
+    name,
+    provider: { id: "opencode", name: "OpenCode" },
+    cost: { input: 0, output: 0 },
+    limit: { context: 128_000 },
+    capabilities: {
+      reasoning: index !== 5,
+      input: { text: true, image: false, audio: false, video: false, pdf: false },
+    },
+  }))
+  const [current, setCurrent] = createSignal(models[2])
+  const model = {
+    list: () => models,
+    current,
+    set(value) {
+      setCurrent(models.find((item) => item.id === value?.modelID))
+    },
+  }
+  const open = () => dialog.show(() => <DialogSelectModelUnpaidV2 model={model} />)
+
+  onMount(open)
+
+  return (
+    <Button variant="secondary" onClick={open}>
+      Open select model dialog
+    </Button>
+  )
+}
+
+export default {
+  title: "App/Dialogs/Select Model",
+  id: "app-dialog-select-model",
+}
+
+export const WithoutProviders = {
+  render: () => <SelectModelWithoutProviders />,
+}

+ 96 - 94
packages/app/src/components/dialog-select-model-unpaid-v2.tsx

@@ -1,23 +1,26 @@
 import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
 import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
 import { Icon } from "@opencode-ai/ui/v2/icon"
 import { Icon } from "@opencode-ai/ui/v2/icon"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import { ScrollView } from "@opencode-ai/ui/scroll-view"
 import { Tag } from "@opencode-ai/ui/v2/badge-v2"
 import { Tag } from "@opencode-ai/ui/v2/badge-v2"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useTheme } from "@opencode-ai/ui/theme"
 import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
 import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
 import { useLocal } from "@/context/local"
 import { useLocal } from "@/context/local"
-import { popularProviders, useProviders } from "@/hooks/use-providers"
+import { useProviders } from "@/hooks/use-providers"
 import { decode64 } from "@/utils/base64"
 import { decode64 } from "@/utils/base64"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { ModelTooltip } from "./model-tooltip"
 import { ModelTooltip } from "./model-tooltip"
 
 
 type ModelState = ReturnType<typeof useLocal>["model"]
 type ModelState = ReturnType<typeof useLocal>["model"]
+const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
+const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
 
 
 export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
 export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
   const local = useLocal()
   const local = useLocal()
   const model = props.model ?? local.model
   const model = props.model ?? local.model
   const dialog = useDialog()
   const dialog = useDialog()
+  const theme = useTheme()
   const directory = () => decode64(local.slug())
   const directory = () => decode64(local.slug())
   const providers = useProviders(directory)
   const providers = useProviders(directory)
   const language = useLanguage()
   const language = useLanguage()
@@ -28,6 +31,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
   })
   })
   const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
   const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
     item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
     item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
+  const freeModels = createMemo(() => model.list().filter(isFree))
 
 
   const openProviders = (provider?: string) => {
   const openProviders = (provider?: string) => {
     void import("./dialog-connect-provider").then((x) => {
     void import("./dialog-connect-provider").then((x) => {
@@ -62,111 +66,109 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
   })
   })
 
 
   return (
   return (
-    <DialogV2 containerClass="!h-[min(calc(100vh_-_16px),480px)] !w-[min(calc(100vw_-_16px),560px)]">
+    <DialogV2
+      fit
+      containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]"
+      class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
+    >
       <DialogHeader closeLabel={language.t("common.close")}>
       <DialogHeader closeLabel={language.t("common.close")}>
         <DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
         <DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
       </DialogHeader>
       </DialogHeader>
-      <div class="h-px w-full shrink-0 bg-v2-border-border-muted" />
-      <DialogBody class="min-h-0 flex-1 gap-0">
-        <ScrollView class="min-h-0 flex-1 w-full">
-          <div ref={listEl} class="flex min-h-full flex-col">
-            <div class="flex h-fit w-full flex-col items-start gap-0.5 px-3.5 pb-3.5 pt-3">
-              <div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 pb-2 pt-1">
-                <div class="flex h-5 flex-none flex-row items-center p-0 font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
-                  {language.t("dialog.model.unpaid.freeModels.title")}
-                </div>
+      <DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
+        <div ref={listEl} class="flex min-h-0 flex-col">
+          <div class="flex w-full flex-col items-start pb-3">
+            <div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
+              <div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
+                {language.t("dialog.model.unpaid.freeModels.title")}
               </div>
               </div>
-              <For each={model.list()}>
-                {(item) => (
-                  <TooltipV2
-                    class="w-full"
-                    placement="right-start"
-                    gutter={6}
-                    openDelay={0}
-                    value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
-                  >
-                    <button
-                      type="button"
-                      class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
-                      onClick={() => selectModel(item)}
-                    >
-                      <span class="min-w-0 truncate">{item.name}</span>
-                      <Show when={isFree(item)}>
-                        <Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
-                      </Show>
-                      <Show when={item.latest}>
-                        <Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
-                      </Show>
-                      <Show when={currentKey() === modelKey(item)}>
-                        <Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
-                      </Show>
-                    </button>
-                  </TooltipV2>
-                )}
-              </For>
             </div>
             </div>
-
-            <div class="flex w-full flex-col p-2.5 pt-0">
-              <div class="flex h-fit w-full flex-none grow-0 flex-col items-start gap-0.5 self-stretch rounded-lg bg-v2-background-bg-layer-02 p-1 shadow-[var(--v2-elevation-switch-off)]">
-                <div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 py-1.5">
-                  <div class="flex h-5 flex-none flex-row items-center p-0 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
-                    {language.t("dialog.model.unpaid.addMore.title")}
-                  </div>
-                </div>
-                <div class="flex w-full flex-col">
-                  <For
-                    each={[...providers.popular()].sort((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)
-                    })}
-                  >
-                    {(provider) => (
-                      <button
-                        type="button"
-                        class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
-                        onClick={() => openProviders(provider.id)}
-                      >
-                        <ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-muted" />
-                        <span class="min-w-0 truncate">{provider.name}</span>
-                        <Show when={provider.id === "opencode"}>
-                          <span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
-                            {language.t("dialog.provider.opencode.tagline")}
-                          </span>
-                          <Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
-                        </Show>
-                        <Show when={provider.id === "opencode-go"}>
-                          <span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
-                            {language.t("dialog.provider.opencodeGo.tagline")}
-                          </span>
-                          <Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
-                        </Show>
-                        <Show when={provider.id === "anthropic"}>
-                          <span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
-                            {language.t("dialog.provider.anthropic.note")}
-                          </span>
-                        </Show>
-                      </button>
-                    )}
-                  </For>
+            <For each={freeModels()}>
+              {(item) => (
+                <TooltipV2
+                  class="w-full"
+                  placement="right-start"
+                  gutter={6}
+                  openDelay={0}
+                  contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
+                  value={
+                    <ModelTooltip
+                      model={{ ...item, name: displayModelName(item.name) }}
+                      latest={item.latest}
+                      free={isFree(item)}
+                      v2
+                    />
+                  }
+                >
                   <button
                   <button
                     type="button"
                     type="button"
-                    class="flex h-9 w-full scroll-my-3.5 flex-row items-center justify-start gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
-                    onClick={() => openProviders()}
+                    class="flex w-full scroll-my-3.5 flex-row items-center gap-1.5 rounded-md px-3 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
+                    onClick={() => selectModel(item)}
                   >
                   >
-                    <span class="flex size-4 shrink-0 items-center justify-center text-v2-icon-icon-muted">
-                      <Icon name="dot-grid" size="small" />
-                    </span>
-                    <span class="min-w-0 truncate text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
-                      {language.t("dialog.provider.viewAll")}
-                    </span>
+                    <span class="min-w-0 truncate">{displayModelName(item.name)}</span>
+                    <Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
+                    <Show when={item.latest}>
+                      <Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
+                    </Show>
+                    <Show when={currentKey() === modelKey(item)}>
+                      <Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
+                    </Show>
                   </button>
                   </button>
+                </TooltipV2>
+              )}
+            </For>
+          </div>
+
+          <div class="flex w-full flex-col">
+            <div class="flex w-full flex-col items-start rounded-lg border-[0.5px] border-v2-border-border-muted bg-v2-background-bg-layer-02 p-2.5 pt-2">
+              <div class="flex h-8 w-full select-none items-center px-0.5 pb-2">
+                <div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
+                  {language.t("dialog.model.unpaid.addMore.title")}
                 </div>
                 </div>
               </div>
               </div>
+              <div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
+                <For
+                  each={[...providers.popular()]
+                    .filter((provider) => featuredProviders.includes(provider.id))
+                    .sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
+                >
+                  {(provider) => (
+                    <button
+                      type="button"
+                      class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
+                      classList={{
+                        "border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
+                          theme.mode() !== "dark",
+                        "border-[0.5px] border-v2-border-border-strong": theme.mode() === "dark",
+                      }}
+                      onClick={() => openProviders(provider.id)}
+                    >
+                      <ProviderIcon id={provider.id} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
+                      <span class="flex min-w-0 flex-col">
+                        <span class="truncate">{provider.name}</span>
+                        <Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
+                          <span class="truncate font-[440] text-v2-text-text-muted">
+                            {language.t(
+                              provider.id === "opencode"
+                                ? "dialog.provider.opencode.tagline"
+                                : "dialog.provider.opencodeGo.tagline",
+                            )}
+                          </span>
+                        </Show>
+                      </span>
+                    </button>
+                  )}
+                </For>
+                <button
+                  type="button"
+                  class="col-span-full flex h-8 w-full scroll-my-3.5 items-center justify-start rounded-md px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
+                  onClick={() => openProviders()}
+                >
+                  {language.t("dialog.model.unpaid.viewMoreProviders")}
+                </button>
+              </div>
             </div>
             </div>
           </div>
           </div>
-        </ScrollView>
+        </div>
       </DialogBody>
       </DialogBody>
     </DialogV2>
     </DialogV2>
   )
   )

+ 2 - 0
packages/app/src/i18n/en.ts

@@ -102,6 +102,7 @@ export const dict = {
   "dialog.provider.empty": "No providers found",
   "dialog.provider.empty": "No providers found",
   "dialog.provider.group.popular": "Popular",
   "dialog.provider.group.popular": "Popular",
   "dialog.provider.group.other": "Other",
   "dialog.provider.group.other": "Other",
+  "dialog.provider.custom.label": "Custom OpenAI-compatible provider",
   "dialog.provider.tag.recommended": "Recommended",
   "dialog.provider.tag.recommended": "Recommended",
   "dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more",
   "dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more",
   "dialog.provider.opencode.tagline": "Reliable optimized models",
   "dialog.provider.opencode.tagline": "Reliable optimized models",
@@ -122,6 +123,7 @@ export const dict = {
 
 
   "dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode",
   "dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode",
   "dialog.model.unpaid.addMore.title": "Add more models from popular providers",
   "dialog.model.unpaid.addMore.title": "Add more models from popular providers",
+  "dialog.model.unpaid.viewMoreProviders": "See 70+ more providers",
 
 
   "dialog.provider.viewAll": "Show more providers",
   "dialog.provider.viewAll": "Show more providers",
 
 

+ 7 - 3
packages/app/src/pages/new-session.tsx

@@ -29,7 +29,7 @@ import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
 import { useTitlebarRightMount } from "@/components/titlebar"
 import { useTitlebarRightMount } from "@/components/titlebar"
 import { useCommand } from "@/context/command"
 import { useCommand } from "@/context/command"
 import { useProviders } from "@/hooks/use-providers"
 import { useProviders } from "@/hooks/use-providers"
-import { useSettingsCommand, useSettingsDialog } from "@/components/settings-dialog"
+import { useSettingsCommand } from "@/components/settings-dialog"
 import { Persist, persisted } from "@/utils/persist"
 import { Persist, persisted } from "@/utils/persist"
 import createPresence from "solid-presence"
 import createPresence from "solid-presence"
 import { useLocal } from "@/context/local"
 import { useLocal } from "@/context/local"
@@ -55,7 +55,11 @@ export default function NewSessionPage() {
   const dialog = useDialog()
   const dialog = useDialog()
   const command = useCommand()
   const command = useCommand()
   const providers = useProviders(() => sdk().directory)
   const providers = useProviders(() => sdk().directory)
-  const openProviderSettings = useSettingsDialog("providers")
+  const openProviders = () => {
+    void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
+      void dialog.show(() => <DialogConnectProvider directory={() => sdk().directory} />)
+    })
+  }
   useSettingsCommand()
   useSettingsCommand()
   const route = useSessionKey()
   const route = useSessionKey()
   const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
   const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
@@ -216,7 +220,7 @@ export default function NewSessionPage() {
             <ProviderTip
             <ProviderTip
               ready={() => serverSync().child(sdk().directory)[0].provider_ready}
               ready={() => serverSync().child(sdk().directory)[0].provider_ready}
               connected={() => providers.paid().length > 0}
               connected={() => providers.paid().length > 0}
-              openProviders={openProviderSettings}
+              openProviders={openProviders}
             />
             />
           </div>
           </div>
         </div>
         </div>

+ 2 - 0
packages/storybook/.storybook/main.ts

@@ -47,6 +47,8 @@ export default defineMain({
           { find: /^@\/context\/language$/, replacement: path.resolve(mocks, "app/context/language.ts") },
           { find: /^@\/context\/language$/, replacement: path.resolve(mocks, "app/context/language.ts") },
           { find: /^@\/context\/platform$/, replacement: path.resolve(mocks, "app/context/platform.ts") },
           { find: /^@\/context\/platform$/, replacement: path.resolve(mocks, "app/context/platform.ts") },
           { find: /^@\/context\/global-sync$/, replacement: path.resolve(mocks, "app/context/global-sync.ts") },
           { find: /^@\/context\/global-sync$/, replacement: path.resolve(mocks, "app/context/global-sync.ts") },
+          { find: /^@\/context\/server-sync$/, replacement: path.resolve(mocks, "app/context/server-sync.ts") },
+          { find: /^@\/context\/server-sdk$/, replacement: path.resolve(mocks, "app/context/server-sdk.ts") },
           { find: /^@\/hooks\/use-providers$/, replacement: path.resolve(mocks, "app/hooks/use-providers.ts") },
           { find: /^@\/hooks\/use-providers$/, replacement: path.resolve(mocks, "app/hooks/use-providers.ts") },
           {
           {
             find: /^@\/components\/dialog-select-model$/,
             find: /^@\/components\/dialog-select-model$/,

+ 64 - 0
packages/storybook/.storybook/mocks/app/context/language.ts

@@ -22,6 +22,69 @@ const dict: Record<string, string> = {
   "prompt.mode.shell": "Shell",
   "prompt.mode.shell": "Shell",
   "prompt.mode.normal": "Prompt",
   "prompt.mode.normal": "Prompt",
   "dialog.model.select.title": "Select model",
   "dialog.model.select.title": "Select model",
+  "dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode",
+  "dialog.model.unpaid.addMore.title": "Add more models from popular providers",
+  "dialog.model.unpaid.viewMoreProviders": "See 70+ more providers",
+  "dialog.provider.opencode.tagline": "Reliable optimized models",
+  "dialog.provider.opencodeGo.tagline": "Low cost subscription for everyone",
+  "dialog.provider.custom.label": "Custom OpenAI-compatible provider",
+  "dialog.provider.search.placeholder": "Search providers",
+  "dialog.provider.empty": "No providers found",
+  "dialog.provider.group.popular": "Popular",
+  "dialog.provider.group.other": "Other",
+  "dialog.provider.tag.recommended": "Recommended",
+  "settings.providers.tag.custom": "Custom",
+  "command.provider.connect": "Connect provider",
+  "provider.connect.title": "Connect {{provider}}",
+  "provider.connect.selectMethod": "Select login method for {{provider}}.",
+  "provider.connect.method.apiKey": "API key",
+  "provider.connect.apiKey.description":
+    "Enter your {{provider}} API key to connect your account and use {{provider}} models in OpenCode.",
+  "provider.connect.apiKey.label": "{{provider}} API key",
+  "provider.connect.apiKey.placeholder": "API key",
+  "provider.connect.apiKey.required": "API key is required",
+  "provider.connect.opencodeZen.line1":
+    "OpenCode Zen gives you access to a curated set of reliable optimized models for coding agents.",
+  "provider.connect.opencodeZen.line2":
+    "With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and more.",
+  "provider.connect.opencodeZen.visit.prefix": "Visit ",
+  "provider.connect.opencodeZen.visit.link": "opencode.ai/zen",
+  "provider.connect.opencodeZen.visit.suffix": " to collect your API key.",
+  "provider.connect.oauth.code.visit.prefix": "Visit ",
+  "provider.connect.oauth.code.visit.link": "this link",
+  "provider.connect.oauth.code.visit.suffix":
+    " to collect your authorization code to connect your account and use {{provider}} models in OpenCode.",
+  "provider.connect.oauth.code.label": "{{method}} authorization code",
+  "provider.connect.oauth.code.placeholder": "Authorization code",
+  "provider.connect.oauth.code.required": "Authorization code is required",
+  "provider.connect.oauth.code.invalid": "Invalid authorization code",
+  "provider.connect.oauth.auto.visit.prefix": "Visit ",
+  "provider.connect.oauth.auto.visit.link": "this link",
+  "provider.connect.oauth.auto.visit.suffix":
+    " and enter the code below to connect your account and use {{provider}} models in OpenCode.",
+  "provider.connect.oauth.auto.confirmationCode": "Confirmation code",
+  "provider.connect.status.inProgress": "Authorization in progress...",
+  "provider.connect.status.waiting": "Waiting for authorization...",
+  "provider.connect.status.failed": "Authorization failed: {{error}}",
+  "provider.connect.toast.connected.title": "{{provider}} connected",
+  "provider.connect.toast.connected.description": "{{provider}} models are now available to use.",
+  "common.continue": "Continue",
+  "model.tag.free": "Free",
+  "model.tag.latest": "Latest",
+  "model.input.text": "text",
+  "model.input.image": "image",
+  "model.input.audio": "audio",
+  "model.input.video": "video",
+  "model.input.pdf": "pdf",
+  "model.tooltip.context.label": "Context",
+  "model.tooltip.inputs": "Inputs",
+  "model.tooltip.model": "Model",
+  "model.tooltip.provider": "Provider",
+  "model.tooltip.reasoning": "Reasoning",
+  "model.tooltip.reasoning.allowed": "Allows reasoning",
+  "model.tooltip.reasoning.none": "No reasoning",
+  "common.close": "Close",
+  "common.goBack": "Go back",
   "common.default": "Default",
   "common.default": "Default",
   "common.key.esc": "Esc",
   "common.key.esc": "Esc",
   "command.category.file": "File",
   "command.category.file": "File",
@@ -73,6 +136,7 @@ function render(template: string, params?: Record<string, unknown>) {
 export function useLanguage() {
 export function useLanguage() {
   return {
   return {
     locale: () => "en" as const,
     locale: () => "en" as const,
+    intl: () => "en-US",
     t(key: string, params?: Record<string, unknown>) {
     t(key: string, params?: Record<string, unknown>) {
       return render(dict[key] ?? key, params)
       return render(dict[key] ?? key, params)
     },
     },

+ 47 - 0
packages/storybook/.storybook/mocks/app/context/server-sdk.ts

@@ -0,0 +1,47 @@
+const providers = [
+  "opencode",
+  "opencode-go",
+  "anthropic",
+  "openai",
+  "google",
+  "openrouter",
+  "vercel",
+  "github-copilot",
+  "302ai",
+  "abacus",
+  "abliteration",
+  "alibaba",
+  "alibaba-cn",
+  "alibaba-coding-plan",
+]
+
+const client = {
+  provider: {
+    auth: async () => ({
+      data: Object.fromEntries(providers.map((provider) => [provider, [{ type: "api", label: "API key" }]])),
+    }),
+    oauth: {
+      authorize: async (input: { method?: number }) => ({
+        data: {
+          url: "https://example.com/oauth",
+          method: input.method === 1 ? ("code" as const) : ("auto" as const),
+          instructions: input.method === 1 ? "Paste the authorization code" : "Confirmation code: ABCD-EFGH",
+        },
+      }),
+      callback: async (input: { method?: number }) => {
+        if (input.method === 0) return new Promise<never>(() => {})
+        return { data: undefined }
+      },
+    },
+  },
+  auth: {
+    set: async () => ({ data: true }),
+  },
+  global: {
+    dispose: async () => ({ data: true }),
+  },
+}
+
+export function useServerSDK() {
+  return () => ({ client })
+}

+ 33 - 0
packages/storybook/.storybook/mocks/app/context/server-sync.ts

@@ -0,0 +1,33 @@
+import type { ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
+
+const data = {
+  provider: {
+    all: new Map(),
+    connected: [],
+    default: {},
+  },
+  provider_auth: {} as Record<string, ProviderAuthMethod[]>,
+  config: { disabled_providers: [] as string[] },
+}
+
+export function mockProviderAuth(provider: string, methods: ProviderAuthMethod[]) {
+  const previous = data.provider_auth[provider]
+  data.provider_auth[provider] = methods
+  return () => {
+    if (previous) {
+      data.provider_auth[provider] = previous
+      return
+    }
+    delete data.provider_auth[provider]
+  }
+}
+
+export function useServerSync() {
+  return () => ({
+    data,
+    set(key: "provider_auth", value: typeof data.provider_auth) {
+      data[key] = value
+    },
+    updateConfig: async () => {},
+  })
+}

+ 24 - 2
packages/storybook/.storybook/mocks/app/hooks/use-providers.ts

@@ -13,6 +13,7 @@ export const popularProviders = [
 
 
 const provider = {
 const provider = {
   id: "anthropic",
   id: "anthropic",
+  name: "Anthropic",
   models: {
   models: {
     [model_id]: {
     [model_id]: {
       id: model_id,
       id: model_id,
@@ -23,12 +24,33 @@ const provider = {
   },
   },
 }
 }
 
 
+const popular = [
+  { id: "opencode", name: "OpenCode Zen", models: {} },
+  { id: "opencode-go", name: "OpenCode Go", models: {} },
+  { id: "openai", name: "OpenAI", models: {} },
+  provider,
+  { id: "google", name: "Google", models: {} },
+  { id: "github-copilot", name: "GitHub Copilot", models: {} },
+]
+
+const catalog = [
+  ...popular,
+  { id: "openrouter", name: "OpenRouter", models: {} },
+  { id: "vercel", name: "Vercel AI Gateway", models: {} },
+  { id: "302ai", name: "302.AI", models: {} },
+  { id: "abacus", name: "Abacus", models: {} },
+  { id: "abliteration", name: "abliteration.ai", models: {} },
+  { id: "alibaba", name: "Alibaba", models: {} },
+  { id: "alibaba-cn", name: "Alibaba (China)", models: {} },
+  { id: "alibaba-coding-plan", name: "Alibaba Coding Plan", models: {} },
+]
+
 export function useProviders() {
 export function useProviders() {
   return {
   return {
-    all: () => [provider],
+    all: () => new Map(catalog.map((item) => [item.id, item])),
     default: () => ({ anthropic: model_id }),
     default: () => ({ anthropic: model_id }),
     connected: () => [provider],
     connected: () => [provider],
     paid: () => [provider],
     paid: () => [provider],
-    popular: () => [provider],
+    popular: () => popular,
   }
   }
 }
 }

+ 1 - 1
packages/ui/src/v2/components/dialog-v2.tsx

@@ -101,7 +101,7 @@ export function Dialog(props: DialogProps) {
             const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null
             const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null
             if (autofocusEl) {
             if (autofocusEl) {
               e.preventDefault()
               e.preventDefault()
-              autofocusEl.focus()
+              autofocusEl.focus({ preventScroll: true })
             }
             }
           }}
           }}
         >
         >