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

+ 1 - 1
packages/desktop/src/app.tsx

@@ -11,7 +11,7 @@ import { LayoutProvider } from "@/context/layout"
 import { GlobalSDKProvider } from "@/context/global-sdk"
 import { GlobalSDKProvider } from "@/context/global-sdk"
 import { SessionProvider } from "@/context/session"
 import { SessionProvider } from "@/context/session"
 import { NotificationProvider } from "@/context/notification"
 import { NotificationProvider } from "@/context/notification"
-import { DialogProvider } from "@/context/dialog"
+import { DialogProvider } from "@opencode-ai/ui/context/dialog"
 import Layout from "@/pages/layout"
 import Layout from "@/pages/layout"
 import Home from "@/pages/home"
 import Home from "@/pages/home"
 import DirectoryLayout from "@/pages/directory-layout"
 import DirectoryLayout from "@/pages/directory-layout"

+ 229 - 248
packages/desktop/src/components/dialog-connect.tsx

@@ -1,10 +1,10 @@
-import { Component, createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
+import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { createStore, produce } from "solid-js/store"
-import { useDialog } from "@/context/dialog"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useGlobalSync } from "@/context/global-sync"
 import { useGlobalSync } from "@/context/global-sync"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { usePlatform } from "@/context/platform"
 import { usePlatform } from "@/context/platform"
-import { ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
+import { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { List, ListRef } from "@opencode-ai/ui/list"
 import { List, ListRef } from "@opencode-ai/ui/list"
 import { Button } from "@opencode-ai/ui/button"
 import { Button } from "@opencode-ai/ui/button"
@@ -18,14 +18,13 @@ import { IconName } from "@opencode-ai/ui/icons/provider"
 import { iife } from "@opencode-ai/util/iife"
 import { iife } from "@opencode-ai/util/iife"
 import { Link } from "@/components/link"
 import { Link } from "@/components/link"
 import { DialogSelectProvider } from "./dialog-select-provider"
 import { DialogSelectProvider } from "./dialog-select-provider"
-import { DialogModel } from "./dialog-model"
+import { DialogSelectModel } from "./dialog-select-model"
 
 
-export const DialogConnect: Component<{ provider: string }> = (props) => {
+export function DialogConnect(props: { provider: string }) {
   const dialog = useDialog()
   const dialog = useDialog()
   const globalSync = useGlobalSync()
   const globalSync = useGlobalSync()
   const globalSDK = useGlobalSDK()
   const globalSDK = useGlobalSDK()
   const platform = usePlatform()
   const platform = usePlatform()
-
   const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
   const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
   const methods = createMemo(
   const methods = createMemo(
     () =>
     () =>
@@ -37,19 +36,19 @@ export const DialogConnect: Component<{ provider: string }> = (props) => {
       ],
       ],
   )
   )
   const [store, setStore] = createStore({
   const [store, setStore] = createStore({
-    method: undefined as undefined | ProviderAuthMethod,
+    methodIndex: undefined as undefined | number,
     authorization: undefined as undefined | ProviderAuthAuthorization,
     authorization: undefined as undefined | ProviderAuthAuthorization,
     state: "pending" as undefined | "pending" | "complete" | "error",
     state: "pending" as undefined | "pending" | "complete" | "error",
     error: undefined as string | undefined,
     error: undefined as string | undefined,
   })
   })
 
 
-  const methodIndex = createMemo(() => methods().findIndex((x) => x.label === store.method?.label))
+  const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
 
 
   async function selectMethod(index: number) {
   async function selectMethod(index: number) {
     const method = methods()[index]
     const method = methods()[index]
     setStore(
     setStore(
       produce((draft) => {
       produce((draft) => {
-        draft.method = method
+        draft.methodIndex = index
         draft.authorization = undefined
         draft.authorization = undefined
         draft.state = undefined
         draft.state = undefined
         draft.error = undefined
         draft.error = undefined
@@ -101,7 +100,6 @@ export const DialogConnect: Component<{ provider: string }> = (props) => {
     if (methods().length === 1) {
     if (methods().length === 1) {
       selectMethod(0)
       selectMethod(0)
     }
     }
-
     document.addEventListener("keydown", handleKey)
     document.addEventListener("keydown", handleKey)
     onCleanup(() => {
     onCleanup(() => {
       document.removeEventListener("keydown", handleKey)
       document.removeEventListener("keydown", handleKey)
@@ -117,8 +115,8 @@ export const DialogConnect: Component<{ provider: string }> = (props) => {
         title: `${provider().name} connected`,
         title: `${provider().name} connected`,
         description: `${provider().name} models are now available to use.`,
         description: `${provider().name} models are now available to use.`,
       })
       })
-      dialog.replace(() => <DialogModel provider={props.provider} />)
-    }, 500)
+      dialog.replace(() => <DialogSelectModel provider={props.provider} />)
+    }, 1000)
   }
   }
 
 
   function goBack() {
   function goBack() {
@@ -128,275 +126,258 @@ export const DialogConnect: Component<{ provider: string }> = (props) => {
     }
     }
     if (store.authorization) {
     if (store.authorization) {
       setStore("authorization", undefined)
       setStore("authorization", undefined)
-      setStore("method", undefined)
+      setStore("methodIndex", undefined)
       return
       return
     }
     }
-    if (store.method) {
-      setStore("method", undefined)
+    if (store.methodIndex) {
+      setStore("methodIndex", undefined)
       return
       return
     }
     }
     dialog.replace(() => <DialogSelectProvider />)
     dialog.replace(() => <DialogSelectProvider />)
   }
   }
 
 
   return (
   return (
-    <Dialog
-      modal
-      defaultOpen
-      onOpenChange={(open) => {
-        if (!open) {
-          dialog.clear()
-        }
-      }}
-    >
-      <Dialog.Header class="px-4.5">
-        <Dialog.Title class="flex items-center">
-          <IconButton tabIndex={-1} icon="arrow-left" variant="ghost" onClick={goBack} />
-        </Dialog.Title>
-        <Dialog.CloseButton tabIndex={-1} />
-      </Dialog.Header>
-      <Dialog.Body>
-        <div class="flex flex-col gap-6 px-2.5 pb-3">
-          <div class="px-2.5 flex gap-4 items-center">
-            <ProviderIcon id={props.provider as IconName} class="size-5 shrink-0 icon-strong-base" />
-            <div class="text-16-medium text-text-strong">
-              <Switch>
-                <Match when={props.provider === "anthropic" && store.method?.label?.toLowerCase().includes("max")}>
-                  Login with Claude Pro/Max
-                </Match>
-                <Match when={true}>Connect {provider().name}</Match>
-              </Switch>
-            </div>
-          </div>
-          <div class="px-2.5 pb-10 flex flex-col gap-6">
+    <Dialog title={<IconButton tabIndex={-1} icon="arrow-left" variant="ghost" onClick={goBack} />}>
+      <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 as IconName} class="size-5 shrink-0 icon-strong-base" />
+          <div class="text-16-medium text-text-strong">
             <Switch>
             <Switch>
-              <Match when={store.method === undefined}>
-                <div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
-                <div class="">
-                  <List
-                    ref={(ref) => (listRef = ref)}
-                    items={methods}
-                    key={(m) => m?.label}
-                    onSelect={async (method, index) => {
-                      if (!method) return
-                      selectMethod(index)
-                    }}
-                  >
-                    {(i) => (
-                      <div class="w-full flex items-center gap-x-4">
-                        <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
-                          <div class="w-2.5 h-0.5 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
-                        </div>
-                        <span>{i.label}</span>
+              <Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
+                Login with Claude Pro/Max
+              </Match>
+              <Match when={true}>Connect {provider().name}</Match>
+            </Switch>
+          </div>
+        </div>
+        <div class="px-2.5 pb-10 flex flex-col gap-6">
+          <Switch>
+            <Match when={store.methodIndex === undefined}>
+              <div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
+              <div class="">
+                <List
+                  ref={(ref) => (listRef = ref)}
+                  items={methods}
+                  key={(m) => m?.label}
+                  onSelect={async (method, index) => {
+                    if (!method) return
+                    selectMethod(index)
+                  }}
+                >
+                  {(i) => (
+                    <div class="w-full flex items-center gap-x-4">
+                      <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
+                        <div class="w-2.5 h-0.5 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
                       </div>
                       </div>
-                    )}
-                  </List>
+                      <span>{i.label}</span>
+                    </div>
+                  )}
+                </List>
+              </div>
+            </Match>
+            <Match when={store.state === "pending"}>
+              <div class="text-14-regular text-text-base">
+                <div class="flex items-center gap-x-4">
+                  <Spinner />
+                  <span>Authorization in progress...</span>
                 </div>
                 </div>
-              </Match>
-              <Match when={store.state === "pending"}>
-                <div class="text-14-regular text-text-base">
-                  <div class="flex items-center gap-x-4">
-                    <Spinner />
-                    <span>Authorization in progress...</span>
-                  </div>
+              </div>
+            </Match>
+            <Match when={store.state === "error"}>
+              <div class="text-14-regular text-text-base">
+                <div class="flex items-center gap-x-4">
+                  <Icon name="circle-ban-sign" class="text-icon-critical-base" />
+                  <span>Authorization failed: {store.error}</span>
                 </div>
                 </div>
-              </Match>
-              <Match when={store.state === "error"}>
-                <div class="text-14-regular text-text-base">
-                  <div class="flex items-center gap-x-4">
-                    <Icon name="circle-ban-sign" class="text-icon-critical-base" />
-                    <span>Authorization failed: {store.error}</span>
-                  </div>
-                </div>
-              </Match>
-              <Match when={store.method?.type === "api"}>
-                {iife(() => {
-                  const [formStore, setFormStore] = createStore({
-                    value: "",
-                    error: undefined as string | undefined,
-                  })
-
-                  async function handleSubmit(e: SubmitEvent) {
-                    e.preventDefault()
+              </div>
+            </Match>
+            <Match when={method()?.type === "api"}>
+              {iife(() => {
+                const [formStore, setFormStore] = createStore({
+                  value: "",
+                  error: undefined as string | undefined,
+                })
 
 
-                    const form = e.currentTarget as HTMLFormElement
-                    const formData = new FormData(form)
-                    const apiKey = formData.get("apiKey") as string
+                async function handleSubmit(e: SubmitEvent) {
+                  e.preventDefault()
 
 
-                    if (!apiKey?.trim()) {
-                      setFormStore("error", "API key is required")
-                      return
-                    }
+                  const form = e.currentTarget as HTMLFormElement
+                  const formData = new FormData(form)
+                  const apiKey = formData.get("apiKey") as string
 
 
-                    setFormStore("error", undefined)
-                    await globalSDK.client.auth.set({
-                      providerID: props.provider,
-                      auth: {
-                        type: "api",
-                        key: apiKey,
-                      },
-                    })
-                    await complete()
+                  if (!apiKey?.trim()) {
+                    setFormStore("error", "API key is required")
+                    return
                   }
                   }
 
 
-                  return (
-                    <div class="flex flex-col gap-6">
-                      <Switch>
-                        <Match when={provider().id === "opencode"}>
-                          <div class="flex flex-col gap-4">
-                            <div class="text-14-regular text-text-base">
-                              OpenCode Zen gives you access to a curated set of reliable optimized models for coding
-                              agents.
-                            </div>
-                            <div class="text-14-regular text-text-base">
-                              With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and
-                              more.
-                            </div>
-                            <div class="text-14-regular text-text-base">
-                              Visit{" "}
-                              <Link href="https://opencode.ai/zen" tabIndex={-1}>
-                                opencode.ai/zen
-                              </Link>{" "}
-                              to collect your API key.
-                            </div>
+                  setFormStore("error", undefined)
+                  await globalSDK.client.auth.set({
+                    providerID: props.provider,
+                    auth: {
+                      type: "api",
+                      key: apiKey,
+                    },
+                  })
+                  await complete()
+                }
+
+                return (
+                  <div class="flex flex-col gap-6">
+                    <Switch>
+                      <Match when={provider().id === "opencode"}>
+                        <div class="flex flex-col gap-4">
+                          <div class="text-14-regular text-text-base">
+                            OpenCode Zen gives you access to a curated set of reliable optimized models for coding
+                            agents.
                           </div>
                           </div>
-                        </Match>
-                        <Match when={true}>
                           <div class="text-14-regular text-text-base">
                           <div class="text-14-regular text-text-base">
-                            Enter your {provider().name} API key to connect your account and use {provider().name}{" "}
-                            models in OpenCode.
+                            With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and more.
                           </div>
                           </div>
-                        </Match>
-                      </Switch>
-                      <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
-                        <TextField
-                          autofocus
-                          type="text"
-                          label={`${provider().name} API key`}
-                          placeholder="API key"
-                          name="apiKey"
-                          value={formStore.value}
-                          onChange={setFormStore.bind(null, "value")}
-                          validationState={formStore.error ? "invalid" : undefined}
-                          error={formStore.error}
-                        />
-                        <Button class="w-auto" type="submit" size="large" variant="primary">
-                          Submit
-                        </Button>
-                      </form>
-                    </div>
-                  )
-                })}
-              </Match>
-              <Match when={store.method?.type === "oauth"}>
-                <Switch>
-                  <Match when={store.authorization?.method === "code"}>
-                    {iife(() => {
-                      const [formStore, setFormStore] = createStore({
-                        value: "",
-                        error: undefined as string | undefined,
-                      })
+                          <div class="text-14-regular text-text-base">
+                            Visit{" "}
+                            <Link href="https://opencode.ai/zen" tabIndex={-1}>
+                              opencode.ai/zen
+                            </Link>{" "}
+                            to collect your API key.
+                          </div>
+                        </div>
+                      </Match>
+                      <Match when={true}>
+                        <div class="text-14-regular text-text-base">
+                          Enter your {provider().name} API key to connect your account and use {provider().name} models
+                          in OpenCode.
+                        </div>
+                      </Match>
+                    </Switch>
+                    <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
+                      <TextField
+                        autofocus
+                        type="text"
+                        label={`${provider().name} API key`}
+                        placeholder="API key"
+                        name="apiKey"
+                        value={formStore.value}
+                        onChange={setFormStore.bind(null, "value")}
+                        validationState={formStore.error ? "invalid" : undefined}
+                        error={formStore.error}
+                      />
+                      <Button class="w-auto" type="submit" size="large" variant="primary">
+                        Submit
+                      </Button>
+                    </form>
+                  </div>
+                )
+              })}
+            </Match>
+            <Match when={method()?.type === "oauth"}>
+              <Switch>
+                <Match when={store.authorization?.method === "code"}>
+                  {iife(() => {
+                    const [formStore, setFormStore] = createStore({
+                      value: "",
+                      error: undefined as string | undefined,
+                    })
 
 
-                      onMount(() => {
-                        if (store.authorization?.method === "code" && store.authorization?.url) {
-                          platform.openLink(store.authorization.url)
-                        }
-                      })
+                    onMount(() => {
+                      if (store.authorization?.method === "code" && store.authorization?.url) {
+                        platform.openLink(store.authorization.url)
+                      }
+                    })
 
 
-                      async function handleSubmit(e: SubmitEvent) {
-                        e.preventDefault()
+                    async function handleSubmit(e: SubmitEvent) {
+                      e.preventDefault()
 
 
-                        const form = e.currentTarget as HTMLFormElement
-                        const formData = new FormData(form)
-                        const code = formData.get("code") as string
+                      const form = e.currentTarget as HTMLFormElement
+                      const formData = new FormData(form)
+                      const code = formData.get("code") as string
 
 
-                        if (!code?.trim()) {
-                          setFormStore("error", "Authorization code is required")
-                          return
-                        }
+                      if (!code?.trim()) {
+                        setFormStore("error", "Authorization code is required")
+                        return
+                      }
 
 
-                        setFormStore("error", undefined)
-                        const { error } = await globalSDK.client.provider.oauth.callback({
-                          providerID: props.provider,
-                          method: methodIndex(),
-                          code,
-                        })
-                        if (!error) {
-                          await complete()
-                          return
-                        }
-                        setFormStore("error", "Invalid authorization code")
+                      setFormStore("error", undefined)
+                      const { error } = await globalSDK.client.provider.oauth.callback({
+                        providerID: props.provider,
+                        method: store.methodIndex,
+                        code,
+                      })
+                      if (!error) {
+                        await complete()
+                        return
                       }
                       }
+                      setFormStore("error", "Invalid authorization code")
+                    }
 
 
-                      return (
-                        <div class="flex flex-col gap-6">
-                          <div class="text-14-regular text-text-base">
-                            Visit <Link href={store.authorization!.url}>this link</Link> to collect your authorization
-                            code to connect your account and use {provider().name} models in OpenCode.
-                          </div>
-                          <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
-                            <TextField
-                              autofocus
-                              type="text"
-                              label={`${store.method?.label} authorization code`}
-                              placeholder="Authorization code"
-                              name="code"
-                              value={formStore.value}
-                              onChange={setFormStore.bind(null, "value")}
-                              validationState={formStore.error ? "invalid" : undefined}
-                              error={formStore.error}
-                            />
-                            <Button class="w-auto" type="submit" size="large" variant="primary">
-                              Submit
-                            </Button>
-                          </form>
+                    return (
+                      <div class="flex flex-col gap-6">
+                        <div class="text-14-regular text-text-base">
+                          Visit <Link href={store.authorization!.url}>this link</Link> to collect your authorization
+                          code to connect your account and use {provider().name} models in OpenCode.
                         </div>
                         </div>
-                      )
-                    })}
-                  </Match>
-                  <Match when={store.authorization?.method === "auto"}>
-                    {iife(() => {
-                      const code = createMemo(() => {
-                        const instructions = store.authorization?.instructions
-                        if (instructions?.includes(":")) {
-                          return instructions?.split(":")[1]?.trim()
-                        }
-                        return instructions
-                      })
+                        <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
+                          <TextField
+                            autofocus
+                            type="text"
+                            label={`${method()?.label} authorization code`}
+                            placeholder="Authorization code"
+                            name="code"
+                            value={formStore.value}
+                            onChange={setFormStore.bind(null, "value")}
+                            validationState={formStore.error ? "invalid" : undefined}
+                            error={formStore.error}
+                          />
+                          <Button class="w-auto" type="submit" size="large" variant="primary">
+                            Submit
+                          </Button>
+                        </form>
+                      </div>
+                    )
+                  })}
+                </Match>
+                <Match when={store.authorization?.method === "auto"}>
+                  {iife(() => {
+                    const code = createMemo(() => {
+                      const instructions = store.authorization?.instructions
+                      if (instructions?.includes(":")) {
+                        return instructions?.split(":")[1]?.trim()
+                      }
+                      return instructions
+                    })
 
 
-                      onMount(async () => {
-                        const result = await globalSDK.client.provider.oauth.callback({
-                          providerID: props.provider,
-                          method: methodIndex(),
-                        })
-                        if (result.error) {
-                          // TODO: show error
-                          dialog.clear()
-                          return
-                        }
-                        await complete()
+                    onMount(async () => {
+                      const result = await globalSDK.client.provider.oauth.callback({
+                        providerID: props.provider,
+                        method: store.methodIndex,
                       })
                       })
+                      if (result.error) {
+                        // TODO: show error
+                        dialog.clear()
+                        return
+                      }
+                      await complete()
+                    })
 
 
-                      return (
-                        <div class="flex flex-col gap-6">
-                          <div class="text-14-regular text-text-base">
-                            Visit <Link href={store.authorization!.url}>this link</Link> and enter the code below to
-                            connect your account and use {provider().name} models in OpenCode.
-                          </div>
-                          <TextField label="Confirmation code" class="font-mono" value={code()} readOnly copyable />
-                          <div class="text-14-regular text-text-base flex items-center gap-4">
-                            <Spinner />
-                            <span>Waiting for authorization...</span>
-                          </div>
+                    return (
+                      <div class="flex flex-col gap-6">
+                        <div class="text-14-regular text-text-base">
+                          Visit <Link href={store.authorization!.url}>this link</Link> and enter the code below to
+                          connect your account and use {provider().name} models in OpenCode.
                         </div>
                         </div>
-                      )
-                    })}
-                  </Match>
-                </Switch>
-              </Match>
-            </Switch>
-          </div>
+                        <TextField label="Confirmation code" class="font-mono" value={code()} readOnly copyable />
+                        <div class="text-14-regular text-text-base flex items-center gap-4">
+                          <Spinner />
+                          <span>Waiting for authorization...</span>
+                        </div>
+                      </div>
+                    )
+                  })}
+                </Match>
+              </Switch>
+            </Match>
+          </Switch>
         </div>
         </div>
-      </Dialog.Body>
+      </div>
     </Dialog>
     </Dialog>
   )
   )
 }
 }

+ 0 - 52
packages/desktop/src/components/dialog-file-select.tsx

@@ -1,52 +0,0 @@
-import { Component } from "solid-js"
-import { useLocal } from "@/context/local"
-import { Dialog } from "@opencode-ai/ui/dialog"
-import { List } from "@opencode-ai/ui/list"
-import { FileIcon } from "@opencode-ai/ui/file-icon"
-import { getDirectory, getFilename } from "@opencode-ai/util/path"
-
-export const DialogFileSelect: Component<{
-  onOpenChange?: (open: boolean) => void
-  onSelect?: (path: string) => void
-}> = (props) => {
-  const local = useLocal()
-  let closeButton!: HTMLButtonElement
-
-  return (
-    <Dialog modal defaultOpen onOpenChange={props.onOpenChange}>
-      <Dialog.Header>
-        <Dialog.Title>Select file</Dialog.Title>
-        <Dialog.CloseButton ref={closeButton} tabIndex={-1} />
-      </Dialog.Header>
-      <Dialog.Body>
-        <List
-          class="px-2.5"
-          search={{ placeholder: "Search files", autofocus: true }}
-          emptyMessage="No files found"
-          items={local.file.searchFiles}
-          key={(x) => x}
-          onSelect={(x) => {
-            if (x) {
-              props.onSelect?.(x)
-            }
-            closeButton.click()
-          }}
-        >
-          {(i) => (
-            <div class="w-full flex items-center justify-between rounded-md">
-              <div class="flex items-center gap-x-2 grow min-w-0">
-                <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
-                <div class="flex items-center text-14-regular">
-                  <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
-                    {getDirectory(i)}
-                  </span>
-                  <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
-                </div>
-              </div>
-            </div>
-          )}
-        </List>
-      </Dialog.Body>
-    </Dialog>
-  )
-}

+ 34 - 52
packages/desktop/src/components/dialog-manage-models.tsx

@@ -1,6 +1,5 @@
 import { Component } from "solid-js"
 import { Component } from "solid-js"
 import { useLocal } from "@/context/local"
 import { useLocal } from "@/context/local"
-import { useDialog } from "@/context/dialog"
 import { popularProviders } from "@/hooks/use-providers"
 import { popularProviders } from "@/hooks/use-providers"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { List } from "@opencode-ai/ui/list"
 import { List } from "@opencode-ai/ui/list"
@@ -8,58 +7,41 @@ import { Switch } from "@opencode-ai/ui/switch"
 
 
 export const DialogManageModels: Component = () => {
 export const DialogManageModels: Component = () => {
   const local = useLocal()
   const local = useLocal()
-  const dialog = useDialog()
-
   return (
   return (
-    <Dialog
-      modal
-      defaultOpen
-      onOpenChange={(open) => {
-        if (!open) {
-          dialog.clear()
-        }
-      }}
-    >
-      <Dialog.Header>
-        <Dialog.Title>Manage models</Dialog.Title>
-        <Dialog.CloseButton tabIndex={-1} />
-      </Dialog.Header>
-      <Dialog.Description>Customize which models appear in the model selector.</Dialog.Description>
-      <Dialog.Body>
-        <List
-          class="px-2.5"
-          search={{ placeholder: "Search models", autofocus: true }}
-          emptyMessage="No model results"
-          key={(x) => `${x?.provider?.id}:${x?.id}`}
-          items={local.model.list()}
-          filterKeys={["provider.name", "name", "id"]}
-          sortBy={(a, b) => a.name.localeCompare(b.name)}
-          groupBy={(x) => x.provider.name}
-          sortGroupsBy={(a, b) => {
-            const aProvider = a.items[0].provider.id
-            const bProvider = b.items[0].provider.id
-            if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
-            if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
-            return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
-          }}
-          onSelect={(x) => {
-            if (!x) return
-            local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !x.visible)
-          }}
-        >
-          {(i) => (
-            <div class="w-full flex items-center justify-between gap-x-2.5">
-              <span>{i.name}</span>
-              <Switch
-                checked={!!i.visible}
-                onChange={(checked) => {
-                  local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
-                }}
-              />
-            </div>
-          )}
-        </List>
-      </Dialog.Body>
+    <Dialog title="Manage models" description="Customize which models appear in the model selector.">
+      <List
+        class="px-2.5"
+        search={{ placeholder: "Search models", autofocus: true }}
+        emptyMessage="No model results"
+        key={(x) => `${x?.provider?.id}:${x?.id}`}
+        items={local.model.list()}
+        filterKeys={["provider.name", "name", "id"]}
+        sortBy={(a, b) => a.name.localeCompare(b.name)}
+        groupBy={(x) => x.provider.name}
+        sortGroupsBy={(a, b) => {
+          const aProvider = a.items[0].provider.id
+          const bProvider = b.items[0].provider.id
+          if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
+          if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
+          return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
+        }}
+        onSelect={(x) => {
+          if (!x) return
+          local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !x.visible)
+        }}
+      >
+        {(i) => (
+          <div class="w-full flex items-center justify-between gap-x-2.5">
+            <span>{i.name}</span>
+            <Switch
+              checked={!!i.visible}
+              onChange={(checked) => {
+                local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
+              }}
+            />
+          </div>
+        )}
+      </List>
     </Dialog>
     </Dialog>
   )
   )
 }
 }

+ 0 - 133
packages/desktop/src/components/dialog-model-unpaid.tsx

@@ -1,133 +0,0 @@
-import { Component, onCleanup, onMount, Show } from "solid-js"
-import { useLocal } from "@/context/local"
-import { useDialog } from "@/context/dialog"
-import { popularProviders, useProviders } from "@/hooks/use-providers"
-import { Button } from "@opencode-ai/ui/button"
-import { Tag } from "@opencode-ai/ui/tag"
-import { Dialog } from "@opencode-ai/ui/dialog"
-import { List, ListRef } from "@opencode-ai/ui/list"
-import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import { IconName } from "@opencode-ai/ui/icons/provider"
-import { DialogSelectProvider } from "./dialog-select-provider"
-import { DialogConnect } from "./dialog-connect"
-
-export const DialogModelUnpaid: Component = () => {
-  const local = useLocal()
-  const dialog = useDialog()
-  const providers = useProviders()
-
-  let listRef: ListRef | undefined
-  const handleKey = (e: KeyboardEvent) => {
-    if (e.key === "Escape") return
-    listRef?.onKeyDown(e)
-  }
-
-  onMount(() => {
-    document.addEventListener("keydown", handleKey)
-    onCleanup(() => {
-      document.removeEventListener("keydown", handleKey)
-    })
-  })
-
-  return (
-    <Dialog
-      modal
-      defaultOpen
-      onOpenChange={(open) => {
-        if (!open) {
-          dialog.clear()
-        }
-      }}
-    >
-      <Dialog.Header>
-        <Dialog.Title>Select model</Dialog.Title>
-        <Dialog.CloseButton tabIndex={-1} />
-      </Dialog.Header>
-      <Dialog.Body>
-        <div class="flex flex-col gap-3 px-2.5">
-          <div class="text-14-medium text-text-base px-2.5">Free models provided by OpenCode</div>
-          <List
-            ref={(ref) => (listRef = ref)}
-            items={local.model.list}
-            current={local.model.current()}
-            key={(x) => `${x.provider.id}:${x.id}`}
-            onSelect={(x) => {
-              local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
-                recent: true,
-              })
-              dialog.clear()
-            }}
-          >
-            {(i) => (
-              <div class="w-full flex items-center gap-x-2.5">
-                <span>{i.name}</span>
-                <Tag>Free</Tag>
-                <Show when={i.latest}>
-                  <Tag>Latest</Tag>
-                </Show>
-              </div>
-            )}
-          </List>
-          <div />
-          <div />
-        </div>
-        <div class="px-1.5 pb-1.5">
-          <div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
-            <div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4">
-              <div class="px-2 text-14-medium text-text-base">Add more models from popular providers</div>
-              <div class="w-full">
-                <List
-                  class="w-full"
-                  key={(x) => x?.id}
-                  items={providers.popular}
-                  activeIcon="plus-small"
-                  sortBy={(a, b) => {
-                    if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
-                      return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
-                    return a.name.localeCompare(b.name)
-                  }}
-                  onSelect={(x) => {
-                    if (!x) return
-                    dialog.replace(() => <DialogConnect provider={x.id} />)
-                  }}
-                >
-                  {(i) => (
-                    <div class="w-full flex items-center gap-x-4">
-                      <ProviderIcon
-                        data-slot="list-item-extra-icon"
-                        id={i.id as IconName}
-                        // TODO: clean this up after we update icon in models.dev
-                        classList={{
-                          "text-icon-weak-base": true,
-                          "size-4 mx-0.5": i.id === "opencode",
-                          "size-5": i.id !== "opencode",
-                        }}
-                      />
-                      <span>{i.name}</span>
-                      <Show when={i.id === "opencode"}>
-                        <Tag>Recommended</Tag>
-                      </Show>
-                      <Show when={i.id === "anthropic"}>
-                        <div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div>
-                      </Show>
-                    </div>
-                  )}
-                </List>
-                <Button
-                  variant="ghost"
-                  class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
-                  icon="dot-grid"
-                  onClick={() => {
-                    dialog.replace(() => <DialogSelectProvider />)
-                  }}
-                >
-                  View all providers
-                </Button>
-              </div>
-            </div>
-          </div>
-        </div>
-      </Dialog.Body>
-    </Dialog>
-  )
-}

+ 0 - 95
packages/desktop/src/components/dialog-model.tsx

@@ -1,95 +0,0 @@
-import { Component, createMemo, Show } from "solid-js"
-import { useLocal } from "@/context/local"
-import { useDialog } from "@/context/dialog"
-import { popularProviders } from "@/hooks/use-providers"
-import { Button } from "@opencode-ai/ui/button"
-import { Tag } from "@opencode-ai/ui/tag"
-import { Dialog } from "@opencode-ai/ui/dialog"
-import { List } from "@opencode-ai/ui/list"
-import { DialogSelectProvider } from "./dialog-select-provider"
-import { DialogManageModels } from "./dialog-manage-models"
-
-export const DialogModel: Component<{ provider?: string }> = (props) => {
-  const local = useLocal()
-  const dialog = useDialog()
-
-  let closeButton!: HTMLButtonElement
-  const models = createMemo(() =>
-    local.model
-      .list()
-      .filter((m) => m.visible)
-      .filter((m) => (props.provider ? m.provider.id === props.provider : true)),
-  )
-
-  return (
-    <Dialog
-      modal
-      defaultOpen
-      onOpenChange={(open) => {
-        if (!open) {
-          dialog.clear()
-        }
-      }}
-    >
-      <Dialog.Header>
-        <Dialog.Title>Select model</Dialog.Title>
-        <Button
-          class="h-7 -my-1 text-14-medium"
-          icon="plus-small"
-          tabIndex={-1}
-          onClick={() => dialog.replace(() => <DialogSelectProvider />)}
-        >
-          Connect provider
-        </Button>
-        <Dialog.CloseButton ref={closeButton} tabIndex={-1} style={{ display: "none" }} />
-      </Dialog.Header>
-      <Dialog.Body>
-        <List
-          class="px-2.5"
-          search={{ placeholder: "Search models", autofocus: true }}
-          emptyMessage="No model results"
-          key={(x) => `${x.provider.id}:${x.id}`}
-          items={models}
-          current={local.model.current()}
-          filterKeys={["provider.name", "name", "id"]}
-          sortBy={(a, b) => a.name.localeCompare(b.name)}
-          groupBy={(x) => x.provider.name}
-          sortGroupsBy={(a, b) => {
-            if (a.category === "Recent" && b.category !== "Recent") return -1
-            if (b.category === "Recent" && a.category !== "Recent") return 1
-            const aProvider = a.items[0].provider.id
-            const bProvider = b.items[0].provider.id
-            if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
-            if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
-            return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
-          }}
-          onSelect={(x) => {
-            local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
-              recent: true,
-            })
-            closeButton.click()
-          }}
-        >
-          {(i) => (
-            <div class="w-full flex items-center gap-x-2.5">
-              <span>{i.name}</span>
-              <Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
-                <Tag>Free</Tag>
-              </Show>
-              <Show when={i.latest}>
-                <Tag>Latest</Tag>
-              </Show>
-            </div>
-          )}
-        </List>
-        <Button
-          variant="ghost"
-          class="ml-2.5 mt-5 mb-6 text-text-base self-start"
-          onClick={() => dialog.replace(() => <DialogManageModels />)}
-        >
-          Manage models
-        </Button>
-      </Dialog.Body>
-    </Dialog>
-  )
-}

+ 44 - 0
packages/desktop/src/components/dialog-select-file.tsx

@@ -0,0 +1,44 @@
+import { useLocal } from "@/context/local"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { List } from "@opencode-ai/ui/list"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+import { useSession } from "@/context/session"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+
+export function DialogSelectFile() {
+  const session = useSession()
+  const local = useLocal()
+  const dialog = useDialog()
+  return (
+    <Dialog title="Select file">
+      <List
+        class="px-2.5"
+        search={{ placeholder: "Search files", autofocus: true }}
+        emptyMessage="No files found"
+        items={local.file.searchFiles}
+        key={(x) => x}
+        onSelect={(path) => {
+          if (path) {
+            session.layout.openTab("file://" + path)
+          }
+          dialog.clear()
+        }}
+      >
+        {(i) => (
+          <div class="w-full flex items-center justify-between rounded-md">
+            <div class="flex items-center gap-x-2 grow min-w-0">
+              <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
+              <div class="flex items-center text-14-regular">
+                <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
+                  {getDirectory(i)}
+                </span>
+                <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
+              </div>
+            </div>
+          </div>
+        )}
+      </List>
+    </Dialog>
+  )
+}

+ 119 - 0
packages/desktop/src/components/dialog-select-model-unpaid.tsx

@@ -0,0 +1,119 @@
+import { Component, onCleanup, onMount, Show } from "solid-js"
+import { useLocal } from "@/context/local"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { popularProviders, useProviders } from "@/hooks/use-providers"
+import { Button } from "@opencode-ai/ui/button"
+import { Tag } from "@opencode-ai/ui/tag"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { List, ListRef } from "@opencode-ai/ui/list"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
+import { IconName } from "@opencode-ai/ui/icons/provider"
+import { DialogSelectProvider } from "./dialog-select-provider"
+import { DialogConnect } from "./dialog-connect"
+
+export const DialogSelectModelUnpaid: Component = () => {
+  const local = useLocal()
+  const dialog = useDialog()
+  const providers = useProviders()
+
+  let listRef: ListRef | undefined
+  const handleKey = (e: KeyboardEvent) => {
+    if (e.key === "Escape") return
+    listRef?.onKeyDown(e)
+  }
+
+  onMount(() => {
+    document.addEventListener("keydown", handleKey)
+    onCleanup(() => {
+      document.removeEventListener("keydown", handleKey)
+    })
+  })
+
+  return (
+    <Dialog title="Select model">
+      <div class="flex flex-col gap-3 px-2.5">
+        <div class="text-14-medium text-text-base px-2.5">Free models provided by OpenCode</div>
+        <List
+          ref={(ref) => (listRef = ref)}
+          items={local.model.list}
+          current={local.model.current()}
+          key={(x) => `${x.provider.id}:${x.id}`}
+          onSelect={(x) => {
+            local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
+              recent: true,
+            })
+            dialog.clear()
+          }}
+        >
+          {(i) => (
+            <div class="w-full flex items-center gap-x-2.5">
+              <span>{i.name}</span>
+              <Tag>Free</Tag>
+              <Show when={i.latest}>
+                <Tag>Latest</Tag>
+              </Show>
+            </div>
+          )}
+        </List>
+        <div />
+        <div />
+      </div>
+      <div class="px-1.5 pb-1.5">
+        <div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
+          <div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4">
+            <div class="px-2 text-14-medium text-text-base">Add more models from popular providers</div>
+            <div class="w-full">
+              <List
+                class="w-full"
+                key={(x) => x?.id}
+                items={providers.popular}
+                activeIcon="plus-small"
+                sortBy={(a, b) => {
+                  if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
+                    return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
+                  return a.name.localeCompare(b.name)
+                }}
+                onSelect={(x) => {
+                  if (!x) return
+                  dialog.replace(() => <DialogConnect provider={x.id} />)
+                }}
+              >
+                {(i) => (
+                  <div class="w-full flex items-center gap-x-4">
+                    <ProviderIcon
+                      data-slot="list-item-extra-icon"
+                      id={i.id as IconName}
+                      // TODO: clean this up after we update icon in models.dev
+                      classList={{
+                        "text-icon-weak-base": true,
+                        "size-4 mx-0.5": i.id === "opencode",
+                        "size-5": i.id !== "opencode",
+                      }}
+                    />
+                    <span>{i.name}</span>
+                    <Show when={i.id === "opencode"}>
+                      <Tag>Recommended</Tag>
+                    </Show>
+                    <Show when={i.id === "anthropic"}>
+                      <div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div>
+                    </Show>
+                  </div>
+                )}
+              </List>
+              <Button
+                variant="ghost"
+                class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
+                icon="dot-grid"
+                onClick={() => {
+                  dialog.replace(() => <DialogSelectProvider />)
+                }}
+              >
+                View all providers
+              </Button>
+            </div>
+          </div>
+        </div>
+      </div>
+    </Dialog>
+  )
+}

+ 85 - 0
packages/desktop/src/components/dialog-select-model.tsx

@@ -0,0 +1,85 @@
+import { Component, createMemo, Show } from "solid-js"
+import { useLocal } from "@/context/local"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { popularProviders } from "@/hooks/use-providers"
+import { Button } from "@opencode-ai/ui/button"
+import { Tag } from "@opencode-ai/ui/tag"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { List } from "@opencode-ai/ui/list"
+import { DialogSelectProvider } from "./dialog-select-provider"
+import { DialogManageModels } from "./dialog-manage-models"
+
+export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
+  const local = useLocal()
+  const dialog = useDialog()
+
+  let closeButton!: HTMLButtonElement
+  const models = createMemo(() =>
+    local.model
+      .list()
+      .filter((m) => m.visible)
+      .filter((m) => (props.provider ? m.provider.id === props.provider : true)),
+  )
+
+  return (
+    <Dialog
+      title="Select model"
+      action={
+        <Button
+          class="h-7 -my-1 text-14-medium"
+          icon="plus-small"
+          tabIndex={-1}
+          onClick={() => dialog.replace(() => <DialogSelectProvider />)}
+        >
+          Connect provider
+        </Button>
+      }
+    >
+      <List
+        class="px-2.5"
+        search={{ placeholder: "Search models", autofocus: true }}
+        emptyMessage="No model results"
+        key={(x) => `${x.provider.id}:${x.id}`}
+        items={models}
+        current={local.model.current()}
+        filterKeys={["provider.name", "name", "id"]}
+        sortBy={(a, b) => a.name.localeCompare(b.name)}
+        groupBy={(x) => x.provider.name}
+        sortGroupsBy={(a, b) => {
+          if (a.category === "Recent" && b.category !== "Recent") return -1
+          if (b.category === "Recent" && a.category !== "Recent") return 1
+          const aProvider = a.items[0].provider.id
+          const bProvider = b.items[0].provider.id
+          if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
+          if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
+          return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
+        }}
+        onSelect={(x) => {
+          local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
+            recent: true,
+          })
+          closeButton.click()
+        }}
+      >
+        {(i) => (
+          <div class="w-full flex items-center gap-x-2.5">
+            <span>{i.name}</span>
+            <Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
+              <Tag>Free</Tag>
+            </Show>
+            <Show when={i.latest}>
+              <Tag>Latest</Tag>
+            </Show>
+          </div>
+        )}
+      </List>
+      <Button
+        variant="ghost"
+        class="ml-3 mt-5 mb-6 text-text-base self-start"
+        onClick={() => dialog.replace(() => <DialogManageModels />)}
+      >
+        Manage models
+      </Button>
+    </Dialog>
+  )
+}

+ 47 - 61
packages/desktop/src/components/dialog-select-provider.tsx

@@ -1,5 +1,5 @@
 import { Component, Show } from "solid-js"
 import { Component, Show } from "solid-js"
-import { useDialog } from "@/context/dialog"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { popularProviders, useProviders } from "@/hooks/use-providers"
 import { popularProviders, useProviders } from "@/hooks/use-providers"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { List } from "@opencode-ai/ui/list"
 import { List } from "@opencode-ai/ui/list"
@@ -13,66 +13,52 @@ export const DialogSelectProvider: Component = () => {
   const providers = useProviders()
   const providers = useProviders()
 
 
   return (
   return (
-    <Dialog
-      modal
-      defaultOpen
-      onOpenChange={(open) => {
-        if (!open) {
-          dialog.clear()
-        }
-      }}
-    >
-      <Dialog.Header>
-        <Dialog.Title>Connect provider</Dialog.Title>
-        <Dialog.CloseButton tabIndex={-1} />
-      </Dialog.Header>
-      <Dialog.Body>
-        <List
-          class="px-2.5"
-          search={{ placeholder: "Search providers", autofocus: true }}
-          activeIcon="plus-small"
-          key={(x) => x?.id}
-          items={providers.all}
-          filterKeys={["id", "name"]}
-          groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")}
-          sortBy={(a, b) => {
-            if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
-              return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
-            return a.name.localeCompare(b.name)
-          }}
-          sortGroupsBy={(a, b) => {
-            if (a.category === "Popular" && b.category !== "Popular") return -1
-            if (b.category === "Popular" && a.category !== "Popular") return 1
-            return 0
-          }}
-          onSelect={(x) => {
-            if (!x) return
-            dialog.replace(() => <DialogConnect provider={x.id} />)
-          }}
-        >
-          {(i) => (
-            <div class="px-1.25 w-full flex items-center gap-x-4">
-              <ProviderIcon
-                data-slot="list-item-extra-icon"
-                id={i.id as IconName}
-                // TODO: clean this up after we update icon in models.dev
-                classList={{
-                  "text-icon-weak-base": true,
-                  "size-4 mx-0.5": i.id === "opencode",
-                  "size-5": i.id !== "opencode",
-                }}
-              />
-              <span>{i.name}</span>
-              <Show when={i.id === "opencode"}>
-                <Tag>Recommended</Tag>
-              </Show>
-              <Show when={i.id === "anthropic"}>
-                <div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div>
-              </Show>
-            </div>
-          )}
-        </List>
-      </Dialog.Body>
+    <Dialog title="Connect provider">
+      <List
+        class="px-2.5"
+        search={{ placeholder: "Search providers", autofocus: true }}
+        activeIcon="plus-small"
+        key={(x) => x?.id}
+        items={providers.all}
+        filterKeys={["id", "name"]}
+        groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")}
+        sortBy={(a, b) => {
+          if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
+            return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
+          return a.name.localeCompare(b.name)
+        }}
+        sortGroupsBy={(a, b) => {
+          if (a.category === "Popular" && b.category !== "Popular") return -1
+          if (b.category === "Popular" && a.category !== "Popular") return 1
+          return 0
+        }}
+        onSelect={(x) => {
+          if (!x) return
+          dialog.replace(() => <DialogConnect provider={x.id} />)
+        }}
+      >
+        {(i) => (
+          <div class="px-1.25 w-full flex items-center gap-x-4">
+            <ProviderIcon
+              data-slot="list-item-extra-icon"
+              id={i.id as IconName}
+              // TODO: clean this up after we update icon in models.dev
+              classList={{
+                "text-icon-weak-base": true,
+                "size-4 mx-0.5": i.id === "opencode",
+                "size-5": i.id !== "opencode",
+              }}
+            />
+            <span>{i.name}</span>
+            <Show when={i.id === "opencode"}>
+              <Tag>Recommended</Tag>
+            </Show>
+            <Show when={i.id === "anthropic"}>
+              <div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div>
+            </Show>
+          </div>
+        )}
+      </List>
     </Dialog>
     </Dialog>
   )
   )
 }
 }

+ 6 - 4
packages/desktop/src/components/prompt-input.tsx

@@ -15,9 +15,9 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Select } from "@opencode-ai/ui/select"
 import { Select } from "@opencode-ai/ui/select"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { useDialog } from "@/context/dialog"
-import { DialogModel } from "@/components/dialog-model"
-import { DialogModelUnpaid } from "@/components/dialog-model-unpaid"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { DialogSelectModel } from "@/components/dialog-select-model"
+import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
 import { useProviders } from "@/hooks/use-providers"
 import { useProviders } from "@/hooks/use-providers"
 
 
 interface PromptInputProps {
 interface PromptInputProps {
@@ -616,7 +616,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             <Button
             <Button
               as="div"
               as="div"
               variant="ghost"
               variant="ghost"
-              onClick={() => dialog.push(() => (providers.paid().length > 0 ? <DialogModel /> : <DialogModelUnpaid />))}
+              onClick={() =>
+                dialog.push(() => (providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />))
+              }
             >
             >
               {local.model.current()?.name ?? "Select model"}
               {local.model.current()?.name ?? "Select model"}
               <span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
               <span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>

+ 1 - 1
packages/desktop/src/pages/directory-layout.tsx

@@ -6,7 +6,7 @@ import { LocalProvider } from "@/context/local"
 import { base64Decode } from "@opencode-ai/util/encode"
 import { base64Decode } from "@opencode-ai/util/encode"
 import { DataProvider } from "@opencode-ai/ui/context"
 import { DataProvider } from "@opencode-ai/ui/context"
 import { iife } from "@opencode-ai/util/iife"
 import { iife } from "@opencode-ai/util/iife"
-import { DialogRoot } from "@/context/dialog"
+import { DialogRoot } from "@opencode-ai/ui/context/dialog"
 
 
 export default function Layout(props: ParentProps) {
 export default function Layout(props: ParentProps) {
   const params = useParams()
   const params = useParams()

+ 1 - 1
packages/desktop/src/pages/layout.tsx

@@ -33,7 +33,7 @@ import { useGlobalSDK } from "@/context/global-sdk"
 import { useNotification } from "@/context/notification"
 import { useNotification } from "@/context/notification"
 import { Binary } from "@opencode-ai/util/binary"
 import { Binary } from "@opencode-ai/util/binary"
 import { Header } from "@/components/header"
 import { Header } from "@/components/header"
-import { useDialog } from "@/context/dialog"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { DialogSelectProvider } from "@/components/dialog-select-provider"
 import { DialogSelectProvider } from "@/components/dialog-select-provider"
 
 
 export default function Layout(props: ParentProps) {
 export default function Layout(props: ParentProps) {

+ 5 - 10
packages/desktop/src/pages/session.tsx

@@ -15,7 +15,6 @@ import { Code } from "@opencode-ai/ui/code"
 import { SessionTurn } from "@opencode-ai/ui/session-turn"
 import { SessionTurn } from "@opencode-ai/ui/session-turn"
 import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
 import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
 import { SessionReview } from "@opencode-ai/ui/session-review"
 import { SessionReview } from "@opencode-ai/ui/session-review"
-import { DialogFileSelect } from "@/components/dialog-file-select"
 import {
 import {
   DragDropProvider,
   DragDropProvider,
   DragDropSensors,
   DragDropSensors,
@@ -33,15 +32,17 @@ import { useLayout } from "@/context/layout"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { Terminal } from "@/components/terminal"
 import { Terminal } from "@/components/terminal"
 import { checksum } from "@opencode-ai/util/encode"
 import { checksum } from "@opencode-ai/util/encode"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { DialogSelectFile } from "@/components/dialog-select-file"
 
 
 export default function Page() {
 export default function Page() {
   const layout = useLayout()
   const layout = useLayout()
   const local = useLocal()
   const local = useLocal()
   const sync = useSync()
   const sync = useSync()
   const session = useSession()
   const session = useSession()
+  const dialog = useDialog()
   const [store, setStore] = createStore({
   const [store, setStore] = createStore({
     clickTimer: undefined as number | undefined,
     clickTimer: undefined as number | undefined,
-    fileSelectOpen: false,
     activeDraggable: undefined as string | undefined,
     activeDraggable: undefined as string | undefined,
     activeTerminalDraggable: undefined as string | undefined,
     activeTerminalDraggable: undefined as string | undefined,
   })
   })
@@ -72,7 +73,7 @@ export default function Page() {
     }
     }
     if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") {
     if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") {
       event.preventDefault()
       event.preventDefault()
-      setStore("fileSelectOpen", true)
+      dialog.replace(() => <DialogSelectFile />)
       return
       return
     }
     }
     if (event.ctrlKey && event.key.toLowerCase() === "t") {
     if (event.ctrlKey && event.key.toLowerCase() === "t") {
@@ -388,7 +389,7 @@ export default function Page() {
                       icon="plus-small"
                       icon="plus-small"
                       variant="ghost"
                       variant="ghost"
                       iconSize="large"
                       iconSize="large"
-                      onClick={() => setStore("fileSelectOpen", true)}
+                      onClick={() => dialog.replace(() => <DialogSelectFile />)}
                     />
                     />
                   </Tooltip>
                   </Tooltip>
                 </div>
                 </div>
@@ -610,12 +611,6 @@ export default function Page() {
             </ul>
             </ul>
           </Show>
           </Show>
         </div>
         </div>
-        <Show when={store.fileSelectOpen}>
-          <DialogFileSelect
-            onOpenChange={(open) => setStore("fileSelectOpen", open)}
-            onSelect={(path) => session.layout.openTab("file://" + path)}
-          />
-        </Show>
       </div>
       </div>
       <Show when={layout.terminal.opened()}>
       <Show when={layout.terminal.opened()}>
         <div
         <div

+ 2 - 0
packages/ui/src/components/dialog.css

@@ -60,6 +60,7 @@
       [data-slot="dialog-header"] {
       [data-slot="dialog-header"] {
         display: flex;
         display: flex;
         padding: 16px;
         padding: 16px;
+        padding-left: 20px;
         justify-content: space-between;
         justify-content: space-between;
         align-items: center;
         align-items: center;
         flex-shrink: 0;
         flex-shrink: 0;
@@ -82,6 +83,7 @@
       [data-slot="dialog-description"] {
       [data-slot="dialog-description"] {
         display: flex;
         display: flex;
         padding: 16px;
         padding: 16px;
+        padding-left: 20px;
         padding-top: 0;
         padding-top: 0;
         margin-top: -8px;
         margin-top: -8px;
         justify-content: space-between;
         justify-content: space-between;

+ 36 - 87
packages/ui/src/components/dialog.tsx

@@ -1,96 +1,45 @@
-import {
-  Dialog as Kobalte,
-  DialogRootProps,
-  DialogTitleProps,
-  DialogCloseButtonProps,
-  DialogDescriptionProps,
-} from "@kobalte/core/dialog"
-import { ComponentProps, type JSX, onCleanup, onMount, Show, splitProps } from "solid-js"
+import { Dialog as Kobalte } from "@kobalte/core/dialog"
+import { ComponentProps, JSXElement, Match, ParentProps, Show, Switch } from "solid-js"
 import { IconButton } from "./icon-button"
 import { IconButton } from "./icon-button"
 
 
-export interface DialogProps extends DialogRootProps {
-  trigger?: JSX.Element
+export interface DialogProps extends ParentProps {
+  title?: JSXElement
+  description?: JSXElement
+  action?: JSXElement
   class?: ComponentProps<"div">["class"]
   class?: ComponentProps<"div">["class"]
   classList?: ComponentProps<"div">["classList"]
   classList?: ComponentProps<"div">["classList"]
 }
 }
 
 
-function DialogRoot(props: DialogProps) {
-  let trigger!: HTMLElement
-  const [local, others] = splitProps(props, ["trigger", "class", "classList", "children"])
-
-  const resetTabIndex = () => {
-    trigger.tabIndex = 0
-  }
-
-  const handleTriggerFocus = (e: FocusEvent & { currentTarget: HTMLElement | null }) => {
-    const firstChild = e.currentTarget?.firstElementChild as HTMLElement
-    if (!firstChild) return
-
-    firstChild.focus()
-    trigger.tabIndex = -1
-
-    firstChild.addEventListener("focusout", resetTabIndex)
-    onCleanup(() => {
-      firstChild.removeEventListener("focusout", resetTabIndex)
-    })
-  }
-
-  onMount(() => {
-    // @ts-ignore
-    document?.activeElement?.blur?.()
-  })
-
+export function Dialog(props: DialogProps) {
   return (
   return (
-    <Kobalte {...others}>
-      <Show when={props.trigger}>
-        <Kobalte.Trigger ref={trigger} data-component="dialog-trigger" onFocusIn={handleTriggerFocus}>
-          {props.trigger}
-        </Kobalte.Trigger>
-      </Show>
-      <Kobalte.Portal>
-        <Kobalte.Overlay data-component="dialog-overlay" />
-        <div data-component="dialog">
-          <div data-slot="dialog-container">
-            <Kobalte.Content
-              data-slot="dialog-content"
-              classList={{
-                ...(local.classList ?? {}),
-                [local.class ?? ""]: !!local.class,
-              }}
-            >
-              {local.children}
-            </Kobalte.Content>
-          </div>
-        </div>
-      </Kobalte.Portal>
-    </Kobalte>
+    <div data-component="dialog">
+      <div data-slot="dialog-container">
+        <Kobalte.Content
+          data-slot="dialog-content"
+          classList={{
+            ...(props.classList ?? {}),
+            [props.class ?? ""]: !!props.class,
+          }}
+        >
+          <Show when={props.title || props.action}>
+            <div data-slot="dialog-header">
+              <Show when={props.title}>
+                <Kobalte.Title data-slot="dialog-title">{props.title}</Kobalte.Title>
+              </Show>
+              <Switch>
+                <Match when={props.action}>{props.action}</Match>
+                <Match when={true}>
+                  <Kobalte.CloseButton data-slot="dialog-close-button" as={IconButton} icon="close" variant="ghost" />
+                </Match>
+              </Switch>
+            </div>
+          </Show>
+          <Show when={props.description}>
+            <Kobalte.Description data-slot="dialog-description">{props.description}</Kobalte.Description>
+          </Show>
+          <div data-slot="dialog-body">{props.children}</div>
+        </Kobalte.Content>
+      </div>
+    </div>
   )
   )
 }
 }
-
-function DialogHeader(props: ComponentProps<"div">) {
-  return <div data-slot="dialog-header" {...props} />
-}
-
-function DialogBody(props: ComponentProps<"div">) {
-  return <div data-slot="dialog-body" {...props} />
-}
-
-function DialogTitle(props: DialogTitleProps & ComponentProps<"h2">) {
-  return <Kobalte.Title data-slot="dialog-title" {...props} />
-}
-
-function DialogDescription(props: DialogDescriptionProps & ComponentProps<"p">) {
-  return <Kobalte.Description data-slot="dialog-description" {...props} />
-}
-
-function DialogCloseButton(props: DialogCloseButtonProps & ComponentProps<"button">) {
-  return <Kobalte.CloseButton data-slot="dialog-close-button" as={IconButton} icon="close" variant="ghost" {...props} />
-}
-
-export const Dialog = Object.assign(DialogRoot, {
-  Header: DialogHeader,
-  Title: DialogTitle,
-  Description: DialogDescription,
-  CloseButton: DialogCloseButton,
-  Body: DialogBody,
-})

+ 18 - 19
packages/desktop/src/context/dialog.tsx → packages/ui/src/context/dialog.tsx

@@ -1,4 +1,4 @@
-import { createEffect, For, onCleanup, Show, type JSX } from "solid-js"
+import { For, Show, type JSX } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 
 
@@ -14,23 +14,6 @@ export const { use: useDialog, provider: DialogProvider } = createSimpleContext(
       }[],
       }[],
     })
     })
 
 
-    function handleKeyDown(e: KeyboardEvent) {
-      if (e.key === "Escape" && store.stack.length > 0) {
-        const current = store.stack.at(-1)!
-        current.onClose?.()
-        setStore("stack", store.stack.slice(0, -1))
-        e.preventDefault()
-        e.stopPropagation()
-      }
-    }
-
-    createEffect(() => {
-      document.addEventListener("keydown", handleKeyDown, true)
-      onCleanup(() => {
-        document.removeEventListener("keydown", handleKeyDown, true)
-      })
-    })
-
     return {
     return {
       get stack() {
       get stack() {
         return store.stack
         return store.stack
@@ -59,6 +42,8 @@ export const { use: useDialog, provider: DialogProvider } = createSimpleContext(
   },
   },
 })
 })
 
 
+import { Dialog as Kobalte } from "@kobalte/core/dialog"
+
 export function DialogRoot(props: { children?: JSX.Element }) {
 export function DialogRoot(props: { children?: JSX.Element }) {
   const dialog = useDialog()
   const dialog = useDialog()
   return (
   return (
@@ -69,7 +54,21 @@ export function DialogRoot(props: { children?: JSX.Element }) {
           <For each={dialog.stack}>
           <For each={dialog.stack}>
             {(item, index) => (
             {(item, index) => (
               <Show when={index() === dialog.stack.length - 1}>
               <Show when={index() === dialog.stack.length - 1}>
-                {typeof item.element === "function" ? item.element() : item.element}
+                <Kobalte
+                  modal
+                  defaultOpen
+                  onOpenChange={(open) => {
+                    if (!open) {
+                      item.onClose?.()
+                      dialog.pop()
+                    }
+                  }}
+                >
+                  <Kobalte.Portal>
+                    <Kobalte.Overlay data-component="dialog-overlay" />
+                    {typeof item.element === "function" ? item.element() : item.element}
+                  </Kobalte.Portal>
+                </Kobalte>
               </Show>
               </Show>
             )}
             )}
           </For>
           </For>

+ 1 - 0
packages/ui/src/context/index.ts

@@ -1,3 +1,4 @@
 export * from "./helper"
 export * from "./helper"
 export * from "./data"
 export * from "./data"
 export * from "./diff"
 export * from "./diff"
+export * from "./dialog"