Browse Source

wip(desktop): progress

Adam 2 months ago
parent
commit
85cfa226c3

+ 168 - 53
packages/desktop/src/components/prompt-input.tsx

@@ -17,6 +17,13 @@ import { Select } from "@opencode-ai/ui/select"
 import { Tag } from "@opencode-ai/ui/tag"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { useLayout } from "@/context/layout"
+import { popularProviders, useProviders } from "@/hooks/use-providers"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { List, ListRef } from "@opencode-ai/ui/list"
+import { iife } from "@opencode-ai/util/iife"
+import { Input } from "@opencode-ai/ui/input"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
+import { IconName } from "@opencode-ai/ui/icons/provider"
 
 interface PromptInputProps {
   class?: string
@@ -58,6 +65,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const local = useLocal()
   const session = useSession()
   const layout = useLayout()
+  const providers = useProviders()
   let editorRef!: HTMLDivElement
 
   const [store, setStore] = createStore<{
@@ -461,60 +469,167 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               <Icon name="chevron-down" size="small" />
             </Button>
             <Show when={layout.dialog.opened() === "model"}>
-              <SelectDialog
-                defaultOpen
-                onOpenChange={(open) => {
-                  if (open) {
-                    layout.dialog.open("model")
-                  } else {
-                    layout.dialog.close("model")
-                  }
-                }}
-                title="Select model"
-                placeholder="Search models"
-                emptyMessage="No model results"
-                key={(x) => `${x.provider.id}:${x.id}`}
-                items={local.model.list()}
-                current={local.model.current()}
-                filterKeys={["provider.name", "name", "id"]}
-                // groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)}
-                groupBy={(x) => x.provider.name}
-                sortGroupsBy={(a, b) => {
-                  const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
-                  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 (order.includes(aProvider) && !order.includes(bProvider)) return -1
-                  if (!order.includes(aProvider) && order.includes(bProvider)) return 1
-                  return order.indexOf(aProvider) - order.indexOf(bProvider)
-                }}
-                onSelect={(x) =>
-                  local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true })
-                }
-                actions={
-                  <Button
-                    class="h-7 -my-1 text-14-medium"
-                    icon="plus-small"
-                    tabIndex={-1}
-                    onClick={() => layout.dialog.open("provider")}
+              <Switch>
+                <Match when={providers().connected().length > 0}>
+                  <SelectDialog
+                    defaultOpen
+                    onOpenChange={(open) => {
+                      if (open) {
+                        layout.dialog.open("model")
+                      } else {
+                        layout.dialog.close("model")
+                      }
+                    }}
+                    title="Select model"
+                    placeholder="Search models"
+                    emptyMessage="No model results"
+                    key={(x) => `${x.provider.id}:${x.id}`}
+                    items={local.model.list()}
+                    current={local.model.current()}
+                    filterKeys={["provider.name", "name", "id"]}
+                    // groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)}
+                    groupBy={(x) => x.provider.name}
+                    sortGroupsBy={(a, b) => {
+                      if (a.category === "Recent" && b.category !== "Recent") return -1
+                      if (b.category === "Recent" && a.category !== "Recent") return 1
+                      const aProvider = a.items[0].provider.id
+                      const bProvider = b.items[0].provider.id
+                      if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
+                      if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
+                      return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
+                    }}
+                    onSelect={(x) =>
+                      local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true })
+                    }
+                    actions={
+                      <Button
+                        class="h-7 -my-1 text-14-medium"
+                        icon="plus-small"
+                        tabIndex={-1}
+                        onClick={() => layout.dialog.open("provider")}
+                      >
+                        Connect provider
+                      </Button>
+                    }
                   >
-                    Connect provider
-                  </Button>
-                }
-              >
-                {(i) => (
-                  <div class="w-full flex items-center gap-x-2.5">
-                    <span>{i.name}</span>
-                    <Show when={!i.cost || i.cost?.input === 0}>
-                      <Tag>Free</Tag>
-                    </Show>
-                    <Show when={i.latest}>
-                      <Tag>Latest</Tag>
-                    </Show>
-                  </div>
-                )}
-              </SelectDialog>
+                    {(i) => (
+                      <div class="w-full flex items-center gap-x-2.5">
+                        <span>{i.name}</span>
+                        <Show when={!i.cost || i.cost?.input === 0}>
+                          <Tag>Free</Tag>
+                        </Show>
+                        <Show when={i.latest}>
+                          <Tag>Latest</Tag>
+                        </Show>
+                      </div>
+                    )}
+                  </SelectDialog>
+                </Match>
+                <Match when={true}>
+                  {iife(() => {
+                    let listRef: ListRef | undefined
+                    const handleKey = (e: KeyboardEvent) => {
+                      if (e.key === "Escape") return
+                      listRef?.onKeyDown(e)
+                    }
+                    return (
+                      <Dialog
+                        modal
+                        defaultOpen
+                        onOpenChange={(open) => {
+                          if (open) {
+                            layout.dialog.open("model")
+                          } else {
+                            layout.dialog.close("model")
+                          }
+                        }}
+                      >
+                        <Dialog.Header>
+                          <Dialog.Title>Select model</Dialog.Title>
+                          <Dialog.CloseButton tabIndex={-1} />
+                        </Dialog.Header>
+                        <Dialog.Body>
+                          <Input hidden type="text" class="opacity-0 size-0" autofocus onKeyDown={handleKey} />
+                          <div class="flex flex-col gap-3 px-2.5">
+                            <div class="text-14-medium text-text-base px-2.5">Free models provided by OpenCode</div>
+                            <List
+                              ref={(ref) => (listRef = ref)}
+                              items={local.model.list()}
+                              current={local.model.current()}
+                              key={(x) => `${x.provider.id}:${x.id}`}
+                              onSelect={(x) => {
+                                local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
+                                  recent: true,
+                                })
+                                layout.dialog.close("model")
+                              }}
+                            >
+                              {(i) => (
+                                <div class="w-full flex items-center gap-x-2.5">
+                                  <span>{i.name}</span>
+                                  <Tag>Free</Tag>
+                                  <Show when={i.latest}>
+                                    <Tag>Latest</Tag>
+                                  </Show>
+                                </div>
+                              )}
+                            </List>
+                            <div />
+                            <div />
+                          </div>
+                          <div class="px-1.5 pb-1.5">
+                            <div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
+                              <div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-6">
+                                <div class="px-2 text-14-medium text-text-base">
+                                  Add more models from popular providers
+                                </div>
+                                <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) => {
+                                    layout.dialog.close("model")
+                                  }}
+                                >
+                                  {(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>
+                              </div>
+                            </div>
+                          </div>
+                        </Dialog.Body>
+                      </Dialog>
+                    )
+                  })}
+                </Match>
+              </Switch>
             </Show>
           </div>
           <Tooltip

+ 31 - 0
packages/desktop/src/hooks/use-providers.ts

@@ -0,0 +1,31 @@
+import { useGlobalSync } from "@/context/global-sync"
+import { base64Decode } from "@opencode-ai/util/encode"
+import { useParams } from "@solidjs/router"
+import { createMemo } from "solid-js"
+
+export const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
+
+export function useProviders() {
+  const params = useParams()
+  const globalSync = useGlobalSync()
+  const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
+  const providers = createMemo(() => {
+    if (currentDirectory()) {
+      const [projectStore] = globalSync.child(currentDirectory())
+      return projectStore.provider
+    }
+    return globalSync.data.provider
+  })
+  const connected = createMemo(() =>
+    providers().all.filter(
+      (p) => providers().connected.includes(p.id) && Object.values(p.models).find((m) => m.cost?.input),
+    ),
+  )
+  const popular = createMemo(() => providers().all.filter((p) => popularProviders.includes(p.id)))
+  return createMemo(() => ({
+    all: providers().all,
+    default: providers().default,
+    popular,
+    connected,
+  }))
+}

+ 4 - 15
packages/desktop/src/pages/layout.tsx

@@ -33,8 +33,7 @@ import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
 import { SelectDialog } from "@opencode-ai/ui/select-dialog"
 import { Tag } from "@opencode-ai/ui/tag"
 import { IconName } from "@opencode-ai/ui/icons/provider"
-
-const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
+import { popularProviders, useProviders } from "@/hooks/use-providers"
 
 export default function Layout(props: ParentProps) {
   const [store, setStore] = createStore({
@@ -50,18 +49,7 @@ export default function Layout(props: ParentProps) {
   const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
   const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? [])
   const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
-  const providers = createMemo(() => {
-    if (currentDirectory()) {
-      const [projectStore] = globalSync.child(currentDirectory())
-      return projectStore.provider
-    }
-    return globalSync.data.provider
-  })
-  const connectedProviders = createMemo(() =>
-    providers().all.filter(
-      (p) => providers().connected.includes(p.id) && Object.values(p.models).find((m) => m.cost?.input),
-    ),
-  )
+  const providers = useProviders()
 
   function navigateToProject(directory: string | undefined) {
     if (!directory) return
@@ -493,7 +481,7 @@ export default function Layout(props: ParentProps) {
           </div>
           <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
             <Switch>
-              <Match when={!connectedProviders().length && layout.sidebar.opened()}>
+              <Match when={!providers().connected().length && layout.sidebar.opened()}>
                 <div class="rounded-md bg-background-stronger shadow-xs-border-base">
                   <div class="p-3 flex flex-col gap-2">
                     <div class="text-12-medium text-text-strong">Getting started</div>
@@ -599,6 +587,7 @@ export default function Layout(props: ParentProps) {
             {(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={{

+ 9 - 1
packages/ui/src/components/input.tsx

@@ -7,6 +7,7 @@ export interface InputProps
     Partial<Pick<ComponentProps<typeof Kobalte>, "value" | "onChange" | "onKeyDown">> {
   label?: string
   hideLabel?: boolean
+  hidden?: boolean
   description?: string
 }
 
@@ -14,6 +15,7 @@ export function Input(props: InputProps) {
   const [local, others] = splitProps(props, [
     "class",
     "label",
+    "hidden",
     "hideLabel",
     "description",
     "value",
@@ -21,7 +23,13 @@ export function Input(props: InputProps) {
     "onKeyDown",
   ])
   return (
-    <Kobalte data-component="input" value={local.value} onChange={local.onChange} onKeyDown={local.onKeyDown}>
+    <Kobalte
+      data-component="input"
+      style={{ height: local.hidden ? 0 : undefined }}
+      value={local.value}
+      onChange={local.onChange}
+      onKeyDown={local.onKeyDown}
+    >
       <Show when={local.label}>
         <Kobalte.Label data-slot="input-label" classList={{ "sr-only": local.hideLabel }}>
           {local.label}

+ 8 - 0
packages/ui/src/components/list.css

@@ -97,10 +97,18 @@
           [data-slot="list-item-active-icon"] {
             display: block;
           }
+          [data-slot="list-item-extra-icon"] {
+            color: var(--icon-strong-base) !important;
+          }
         }
         &:active {
           background: var(--surface-raised-base-active);
         }
+        &:hover {
+          [data-slot="list-item-extra-icon"] {
+            color: var(--icon-strong-base) !important;
+          }
+        }
       }
     }
   }

+ 2 - 1
packages/ui/src/components/list.tsx

@@ -4,6 +4,7 @@ import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
 import { Icon, IconProps } from "./icon"
 
 export interface ListProps<T> extends FilteredListProps<T> {
+  class?: string
   children: (item: T) => JSX.Element
   emptyMessage?: string
   onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void
@@ -90,7 +91,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
   })
 
   return (
-    <div ref={setScrollRef} data-component="list">
+    <div ref={setScrollRef} data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
       <Show
         when={flat().length > 0}
         fallback={