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

+ 1 - 1
packages/desktop/src/components/dialog-connect.tsx

@@ -117,7 +117,7 @@ export const DialogConnect: Component<{ provider: string }> = (props) => {
         title: `${provider().name} connected`,
         description: `${provider().name} models are now available to use.`,
       })
-      dialog.replace(() => <DialogModel connectedProvider={props.provider} />)
+      dialog.replace(() => <DialogModel provider={props.provider} />)
     }, 500)
   }
 

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

@@ -0,0 +1,52 @@
+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>
+  )
+}

+ 65 - 0
packages/desktop/src/components/dialog-manage-models.tsx

@@ -0,0 +1,65 @@
+import { Component } from "solid-js"
+import { useLocal } from "@/context/local"
+import { useDialog } from "@/context/dialog"
+import { popularProviders } from "@/hooks/use-providers"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { List } from "@opencode-ai/ui/list"
+import { Switch } from "@opencode-ai/ui/switch"
+
+export const DialogManageModels: Component = () => {
+  const local = useLocal()
+  const dialog = useDialog()
+
+  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>
+  )
+}

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

@@ -0,0 +1,133 @@
+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>
+  )
+}

+ 81 - 194
packages/desktop/src/components/dialog-model.tsx

@@ -1,208 +1,95 @@
-import { Component, createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js"
+import { Component, createMemo, Show } from "solid-js"
 import { useLocal } from "@/context/local"
 import { useDialog } from "@/context/dialog"
-import { popularProviders, useProviders } from "@/hooks/use-providers"
-import { SelectDialog } from "@opencode-ai/ui/select-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, ListRef } from "@opencode-ai/ui/list"
-import { iife } from "@opencode-ai/util/iife"
-import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import { IconName } from "@opencode-ai/ui/icons/provider"
+import { List } from "@opencode-ai/ui/list"
 import { DialogSelectProvider } from "./dialog-select-provider"
-import { DialogConnect } from "./dialog-connect"
+import { DialogManageModels } from "./dialog-manage-models"
 
-export const DialogModel: Component<{ connectedProvider?: string }> = (props) => {
+export const DialogModel: Component<{ provider?: string }> = (props) => {
   const local = useLocal()
   const dialog = useDialog()
-  const providers = useProviders()
 
-  return (
-    <Switch>
-      <Match when={providers.paid().length > 0}>
-        {iife(() => {
-          const models = createMemo(() =>
-            local.model
-              .list()
-              .filter((m) => m.visible)
-              .filter((m) => (props.connectedProvider ? m.provider.id === props.connectedProvider : true)),
-          )
-          return (
-            <SelectDialog
-              defaultOpen
-              onOpenChange={(open) => {
-                if (!open) {
-                  dialog.clear()
-                }
-              }}
-              title="Select model"
-              placeholder="Search models"
-              emptyMessage="No model results"
-              key={(x) => `${x.provider.id}:${x.id}`}
-              items={models}
-              current={local.model.current()}
-              filterKeys={["provider.name", "name", "id"]}
-              sortBy={(a, b) => a.name.localeCompare(b.name)}
-              groupBy={(x) => x.provider.name}
-              sortGroupsBy={(a, b) => {
-                if (a.category === "Recent" && b.category !== "Recent") return -1
-                if (b.category === "Recent" && a.category !== "Recent") return 1
-                const aProvider = a.items[0].provider.id
-                const bProvider = b.items[0].provider.id
-                if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
-                if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
-                return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
-              }}
-              onSelect={(x) =>
-                local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
-                  recent: true,
-                })
-              }
-              actions={
-                <Button
-                  class="h-7 -my-1 text-14-medium"
-                  icon="plus-small"
-                  tabIndex={-1}
-                  onClick={() => dialog.replace(() => <DialogSelectProvider />)}
-                >
-                  Connect provider
-                </Button>
-              }
-            >
-              {(i) => (
-                <div class="w-full flex items-center gap-x-2.5">
-                  <span>{i.name}</span>
-                  <Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
-                    <Tag>Free</Tag>
-                  </Show>
-                  <Show when={i.latest}>
-                    <Tag>Latest</Tag>
-                  </Show>
-                </div>
-              )}
-            </SelectDialog>
-          )
-        })}
-      </Match>
-      <Match when={true}>
-        {iife(() => {
-          let listRef: ListRef | undefined
-          const handleKey = (e: KeyboardEvent) => {
-            if (e.key === "Escape") return
-            listRef?.onKeyDown(e)
-          }
+  let closeButton!: HTMLButtonElement
+  const models = createMemo(() =>
+    local.model
+      .list()
+      .filter((m) => m.visible)
+      .filter((m) => (props.provider ? m.provider.id === props.provider : true)),
+  )
 
-          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>
+        <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,
             })
-          })
-
-          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>
-          )
-        })}
-      </Match>
-    </Switch>
+            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>
   )
 }

+ 56 - 45
packages/desktop/src/components/dialog-select-provider.tsx

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

+ 8 - 1
packages/desktop/src/components/prompt-input.tsx

@@ -17,6 +17,8 @@ import { Select } from "@opencode-ai/ui/select"
 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 { useProviders } from "@/hooks/use-providers"
 
 interface PromptInputProps {
   class?: string
@@ -58,6 +60,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const local = useLocal()
   const session = useSession()
   const dialog = useDialog()
+  const providers = useProviders()
   let editorRef!: HTMLDivElement
 
   const [store, setStore] = createStore<{
@@ -610,7 +613,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               class="capitalize"
               variant="ghost"
             />
-            <Button as="div" variant="ghost" onClick={() => dialog.push(() => <DialogModel />)}>
+            <Button
+              as="div"
+              variant="ghost"
+              onClick={() => dialog.push(() => (providers.paid().length > 0 ? <DialogModel /> : <DialogModelUnpaid />))}
+            >
               {local.model.current()?.name ?? "Select model"}
               <span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
               <Icon name="chevron-down" size="small" />

+ 6 - 1
packages/desktop/src/context/local.tsx

@@ -239,7 +239,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       function updateVisibility(model: ModelKey, visibility: "show" | "hide") {
         const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
         if (index >= 0) {
-          setStore("user", index, { visibility: visibility })
+          setStore("user", index, { visibility })
+        } else {
+          setStore("user", (prev) => [...prev, { ...model, visibility }])
         }
       }
 
@@ -264,6 +266,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
         hide(model: ModelKey) {
           updateVisibility(model, "hide")
         },
+        setVisibility(model: ModelKey, visible: boolean) {
+          updateVisibility(model, visible ? "show" : "hide")
+        },
       }
     })()
 

+ 4 - 34
packages/desktop/src/pages/session.tsx

@@ -15,7 +15,7 @@ import { Code } from "@opencode-ai/ui/code"
 import { SessionTurn } from "@opencode-ai/ui/session-turn"
 import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
 import { SessionReview } from "@opencode-ai/ui/session-review"
-import { SelectDialog } from "@opencode-ai/ui/select-dialog"
+import { DialogFileSelect } from "@/components/dialog-file-select"
 import {
   DragDropProvider,
   DragDropSensors,
@@ -611,40 +611,10 @@ export default function Page() {
           </Show>
         </div>
         <Show when={store.fileSelectOpen}>
-          <SelectDialog
-            defaultOpen
-            title="Select file"
-            placeholder="Search files"
-            emptyMessage="No files found"
-            items={local.file.searchFiles}
-            key={(x) => x}
+          <DialogFileSelect
             onOpenChange={(open) => setStore("fileSelectOpen", open)}
-            onSelect={(x) => {
-              if (x) {
-                return session.layout.openTab("file://" + x)
-              }
-              return undefined
-            }}
-          >
-            {(i) => (
-              <div
-                classList={{
-                  "w-full flex items-center justify-between rounded-md": true,
-                }}
-              >
-                <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 class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
-              </div>
-            )}
-          </SelectDialog>
+            onSelect={(path) => session.layout.openTab("file://" + path)}
+          />
         </Show>
       </div>
       <Show when={layout.terminal.opened()}>

+ 23 - 4
packages/ui/src/components/dialog.css

@@ -59,9 +59,7 @@
 
       [data-slot="dialog-header"] {
         display: flex;
-        /* height: 40px; */
-        /* padding: 4px 4px 4px 8px; */
-        padding: 20px;
+        padding: 16px;
         justify-content: space-between;
         align-items: center;
         flex-shrink: 0;
@@ -80,7 +78,28 @@
         }
         /* [data-slot="dialog-close-button"] {} */
       }
-      /* [data-slot="dialog-description"] {} */
+
+      [data-slot="dialog-description"] {
+        display: flex;
+        padding: 16px;
+        padding-top: 0;
+        margin-top: -8px;
+        justify-content: space-between;
+        align-items: center;
+        flex-shrink: 0;
+        align-self: stretch;
+
+        color: var(--text-base);
+
+        /* text-14-regular */
+        font-family: var(--font-family-sans);
+        font-size: 14px;
+        font-style: normal;
+        font-weight: var(--font-weight-regular);
+        line-height: var(--line-height-large); /* 142.857% */
+        letter-spacing: var(--letter-spacing-normal);
+      }
+
       [data-slot="dialog-body"] {
         width: 100%;
         position: relative;

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

@@ -2,6 +2,43 @@
   display: flex;
   flex-direction: column;
   gap: 20px;
+  overflow: hidden;
+
+  [data-slot="list-search"] {
+    display: flex;
+    height: 40px;
+    flex-shrink: 0;
+    padding: 4px 10px 4px 16px;
+    align-items: center;
+    gap: 12px;
+    align-self: stretch;
+
+    border-radius: var(--radius-md);
+    background: var(--surface-base);
+
+    [data-slot="list-search-container"] {
+      display: flex;
+      align-items: center;
+      gap: 16px;
+      flex: 1 0 0;
+
+      [data-slot="list-search-input"] {
+        width: 100%;
+      }
+    }
+  }
+
+  [data-slot="list-scroll"] {
+    display: flex;
+    flex-direction: column;
+    gap: 20px;
+    overflow-y: auto;
+    scrollbar-width: none;
+    -ms-overflow-style: none;
+    &::-webkit-scrollbar {
+      display: none;
+    }
+  }
 
   [data-slot="list-empty-state"] {
     display: flex;
@@ -41,6 +78,7 @@
 
     [data-slot="list-header"] {
       display: flex;
+      z-index: 10;
       height: 28px;
       padding: 0 10px;
       justify-content: space-between;

+ 87 - 54
packages/ui/src/components/list.tsx

@@ -2,6 +2,13 @@ import { createEffect, Show, For, type JSX, createSignal } from "solid-js"
 import { createStore } from "solid-js/store"
 import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
 import { Icon, IconProps } from "./icon"
+import { IconButton } from "./icon-button"
+import { TextField } from "./text-field"
+
+export interface ListSearchProps {
+  placeholder?: string
+  autofocus?: boolean
+}
 
 export interface ListProps<T> extends FilteredListProps<T> {
   class?: string
@@ -10,6 +17,7 @@ export interface ListProps<T> extends FilteredListProps<T> {
   onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void
   activeIcon?: IconProps["name"]
   filter?: string
+  search?: ListSearchProps | boolean
 }
 
 export interface ListRef {
@@ -19,23 +27,22 @@ export interface ListRef {
 
 export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) {
   const [scrollRef, setScrollRef] = createSignal<HTMLDivElement | undefined>(undefined)
+  const [internalFilter, setInternalFilter] = createSignal("")
   const [store, setStore] = createStore({
     mouseActive: false,
   })
 
-  const { filter, grouped, flat, reset, active, setActive, onKeyDown, onInput } = useFilteredList<T>({
-    items: props.items,
-    key: props.key,
-    filterKeys: props.filterKeys,
-    current: props.current,
-    groupBy: props.groupBy,
-    sortBy: props.sortBy,
-    sortGroupsBy: props.sortGroupsBy,
-  })
+  const { filter, grouped, flat, reset, active, setActive, onKeyDown, onInput } = useFilteredList<T>(props)
+
+  const searchProps = () => (typeof props.search === "object" ? props.search : {})
+  const hasSearch = () => !!props.search
 
   createEffect(() => {
-    if (props.filter === undefined) return
-    onInput(props.filter)
+    if (props.filter !== undefined) {
+      onInput(props.filter)
+    } else if (hasSearch()) {
+      onInput(internalFilter())
+    }
   })
 
   createEffect(() => {
@@ -92,52 +99,78 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
   })
 
   return (
-    <div ref={setScrollRef} data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
-      <Show
-        when={flat().length > 0}
-        fallback={
-          <div data-slot="list-empty-state">
-            <div data-slot="list-message">
-              {props.emptyMessage ?? "No results"} for <span data-slot="list-filter">&quot;{filter()}&quot;</span>
-            </div>
+    <div data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
+      <Show when={hasSearch()}>
+        <div data-slot="list-search">
+          <div data-slot="list-search-container">
+            <Icon name="magnifying-glass" />
+            <TextField
+              autofocus={searchProps().autofocus}
+              variant="ghost"
+              data-slot="list-search-input"
+              type="text"
+              value={internalFilter()}
+              onChange={setInternalFilter}
+              onKeyDown={handleKey}
+              placeholder={searchProps().placeholder}
+              spellcheck={false}
+              autocorrect="off"
+              autocomplete="off"
+              autocapitalize="off"
+            />
           </div>
-        }
-      >
-        <For each={grouped()}>
-          {(group) => (
-            <div data-slot="list-group">
-              <Show when={group.category}>
-                <div data-slot="list-header">{group.category}</div>
-              </Show>
-              <div data-slot="list-items">
-                <For each={group.items}>
-                  {(item, i) => (
-                    <button
-                      data-slot="list-item"
-                      data-key={props.key(item)}
-                      data-active={props.key(item) === active()}
-                      data-selected={item === props.current}
-                      onClick={() => handleSelect(item, i())}
-                      onMouseMove={() => {
-                        setStore("mouseActive", true)
-                        setActive(props.key(item))
-                      }}
-                    >
-                      {props.children(item)}
-                      <Show when={item === props.current}>
-                        <Icon data-slot="list-item-selected-icon" name="check-small" />
-                      </Show>
-                      <Show when={props.activeIcon}>
-                        {(icon) => <Icon data-slot="list-item-active-icon" name={icon()} />}
-                      </Show>
-                    </button>
-                  )}
-                </For>
+          <Show when={internalFilter()}>
+            <IconButton icon="circle-x" variant="ghost" onClick={() => setInternalFilter("")} />
+          </Show>
+        </div>
+      </Show>
+      <div ref={setScrollRef} data-slot="list-scroll">
+        <Show
+          when={flat().length > 0}
+          fallback={
+            <div data-slot="list-empty-state">
+              <div data-slot="list-message">
+                {props.emptyMessage ?? "No results"} for <span data-slot="list-filter">&quot;{filter()}&quot;</span>
               </div>
             </div>
-          )}
-        </For>
-      </Show>
+          }
+        >
+          <For each={grouped()}>
+            {(group) => (
+              <div data-slot="list-group">
+                <Show when={group.category}>
+                  <div data-slot="list-header">{group.category}</div>
+                </Show>
+                <div data-slot="list-items">
+                  <For each={group.items}>
+                    {(item, i) => (
+                      <button
+                        data-slot="list-item"
+                        data-key={props.key(item)}
+                        data-active={props.key(item) === active()}
+                        data-selected={item === props.current}
+                        onClick={() => handleSelect(item, i())}
+                        onMouseMove={() => {
+                          setStore("mouseActive", true)
+                          setActive(props.key(item))
+                        }}
+                      >
+                        {props.children(item)}
+                        <Show when={item === props.current}>
+                          <Icon data-slot="list-item-selected-icon" name="check-small" />
+                        </Show>
+                        <Show when={props.activeIcon}>
+                          {(icon) => <Icon data-slot="list-item-active-icon" name={icon()} />}
+                        </Show>
+                      </button>
+                    )}
+                  </For>
+                </div>
+              </div>
+            )}
+          </For>
+        </Show>
+      </div>
     </div>
   )
 }

+ 0 - 44
packages/ui/src/components/select-dialog.css

@@ -1,44 +0,0 @@
-[data-slot="select-dialog-content"] {
-  width: 100%;
-  display: flex;
-  flex-direction: column;
-  overflow: hidden;
-  gap: 20px;
-  padding: 0 10px;
-
-  [data-slot="dialog-body"] {
-    scrollbar-width: none;
-    -ms-overflow-style: none;
-    &::-webkit-scrollbar {
-      display: none;
-    }
-  }
-}
-
-[data-component="select-dialog-input"] {
-  display: flex;
-  height: 40px;
-  flex-shrink: 0;
-  padding: 4px 10px 4px 16px;
-  align-items: center;
-  gap: 12px;
-  align-self: stretch;
-
-  border-radius: var(--radius-md);
-  background: var(--surface-base);
-
-  [data-slot="select-dialog-input-container"] {
-    display: flex;
-    align-items: center;
-    gap: 16px;
-    flex: 1 0 0;
-
-    /* [data-slot="select-dialog-icon"] {} */
-
-    [data-slot="select-dialog-input"] {
-      width: 100%;
-    }
-  }
-
-  /* [data-slot="select-dialog-clear-button"] {} */
-}

+ 0 - 93
packages/ui/src/components/select-dialog.tsx

@@ -1,93 +0,0 @@
-import { Show, type JSX, splitProps, createSignal } from "solid-js"
-import { Dialog, DialogProps } from "./dialog"
-import { Icon } from "./icon"
-import { IconButton } from "./icon-button"
-import { List, ListRef, ListProps } from "./list"
-import { TextField } from "./text-field"
-
-interface SelectDialogProps<T>
-  extends Omit<ListProps<T>, "filter">,
-    Pick<DialogProps, "trigger" | "onOpenChange" | "defaultOpen"> {
-  title: string
-  placeholder?: string
-  actions?: JSX.Element
-}
-
-export function SelectDialog<T>(props: SelectDialogProps<T>) {
-  const [dialog, others] = splitProps(props, ["trigger", "onOpenChange", "defaultOpen"])
-  let closeButton!: HTMLButtonElement
-  let inputRef: HTMLInputElement | undefined
-  const [filter, setFilter] = createSignal("")
-  let listRef: ListRef | undefined
-
-  const handleSelect = (item: T | undefined, index: number) => {
-    others.onSelect?.(item, index)
-    closeButton.click()
-  }
-
-  const handleKey = (e: KeyboardEvent) => {
-    if (e.key === "Escape") return
-    listRef?.onKeyDown(e)
-  }
-
-  const handleOpenChange = (open: boolean) => {
-    if (!open) setFilter("")
-    props.onOpenChange?.(open)
-  }
-
-  return (
-    <Dialog modal {...dialog} onOpenChange={handleOpenChange}>
-      <Dialog.Header>
-        <Dialog.Title>{others.title}</Dialog.Title>
-        <Show when={others.actions}>{others.actions}</Show>
-        <Dialog.CloseButton ref={closeButton} tabIndex={-1} style={{ display: others.actions ? "none" : undefined }} />
-      </Dialog.Header>
-      <div data-slot="select-dialog-content">
-        <div data-component="select-dialog-input">
-          <div data-slot="select-dialog-input-container">
-            <Icon name="magnifying-glass" />
-            <TextField
-              ref={inputRef}
-              autofocus
-              variant="ghost"
-              data-slot="select-dialog-input"
-              type="text"
-              value={filter()}
-              onChange={setFilter}
-              onKeyDown={handleKey}
-              placeholder={others.placeholder}
-              spellcheck={false}
-              autocorrect="off"
-              autocomplete="off"
-              autocapitalize="off"
-            />
-          </div>
-          <Show when={filter()}>
-            <IconButton icon="circle-x" variant="ghost" onClick={() => setFilter("")} />
-          </Show>
-        </div>
-        <Dialog.Body>
-          <List
-            ref={(ref) => {
-              listRef = ref
-            }}
-            items={others.items}
-            key={others.key}
-            filterKeys={others.filterKeys}
-            current={others.current}
-            groupBy={others.groupBy}
-            sortBy={others.sortBy}
-            sortGroupsBy={others.sortGroupsBy}
-            emptyMessage={others.emptyMessage}
-            activeIcon={others.activeIcon}
-            filter={filter()}
-            onSelect={handleSelect}
-            onKeyEvent={others.onKeyEvent}
-          >
-            {others.children}
-          </List>
-        </Dialog.Body>
-      </div>
-    </Dialog>
-  )
-}

+ 0 - 2
packages/ui/src/components/session-turn.css

@@ -37,7 +37,6 @@
     top: 0;
     background-color: var(--background-stronger);
     z-index: 21;
-    /* padding-bottom: clamp(0px, calc(8px - var(--scroll-y) * 0.16), 8px); */
   }
 
   [data-slot="session-turn-response-trigger"] {
@@ -297,7 +296,6 @@
   [data-slot="session-turn-collapsible"] {
     gap: 32px;
     overflow: visible;
-    /* margin-top: clamp(8px, calc(24px - var(--scroll-y) * 0.32), 24px); */
   }
 
   [data-slot="session-turn-collapsible-trigger-content"] {

+ 3 - 1
packages/ui/src/components/session-turn.tsx

@@ -60,6 +60,8 @@ export function SessionTurn(
 
   function handleScroll() {
     if (!scrollRef) return
+    // prevents scroll loops
+    if (working() && scrollRef.scrollTop < 100) return
     setState("scrollY", scrollRef.scrollTop)
     if (state.autoScrolling) return
     const { scrollTop, scrollHeight, clientHeight } = scrollRef
@@ -79,7 +81,7 @@ export function SessionTurn(
     if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return
     setState("autoScrolling", true)
     requestAnimationFrame(() => {
-      scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "auto" })
+      scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" })
       requestAnimationFrame(() => {
         setState("autoScrolling", false)
       })

+ 131 - 0
packages/ui/src/components/switch.css

@@ -0,0 +1,131 @@
+[data-component="switch"] {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  cursor: default;
+
+  [data-slot="switch-input"] {
+    position: absolute;
+    width: 1px;
+    height: 1px;
+    padding: 0;
+    margin: -1px;
+    overflow: hidden;
+    clip: rect(0, 0, 0, 0);
+    white-space: nowrap;
+    border-width: 0;
+  }
+
+  [data-slot="switch-control"] {
+    display: inline-flex;
+    align-items: center;
+    width: 28px;
+    height: 16px;
+    flex-shrink: 0;
+    border-radius: 3px;
+    border: 1px solid var(--border-weak-base);
+    background: var(--surface-base);
+    transition:
+      background-color 150ms,
+      border-color 150ms;
+  }
+
+  [data-slot="switch-thumb"] {
+    width: 14px;
+    height: 14px;
+    box-sizing: content-box;
+
+    border-radius: 2px;
+    border: 1px solid var(--border-base);
+    background: var(--icon-invert-base);
+
+    /* shadows/shadow-xs */
+    box-shadow:
+      0 1px 2px -1px rgba(19, 16, 16, 0.04),
+      0 1px 2px 0 rgba(19, 16, 16, 0.06),
+      0 1px 3px 0 rgba(19, 16, 16, 0.08);
+
+    transform: translateX(-1px);
+    transition:
+      transform 150ms,
+      background-color 150ms;
+  }
+
+  [data-slot="switch-label"] {
+    user-select: none;
+    color: var(--text-base);
+    font-family: var(--font-family-sans);
+    font-size: var(--font-size-small);
+    font-style: normal;
+    font-weight: var(--font-weight-regular);
+    line-height: var(--line-height-large);
+    letter-spacing: var(--letter-spacing-normal);
+  }
+
+  [data-slot="switch-description"] {
+    color: var(--text-base);
+    font-family: var(--font-family-sans);
+    font-size: 12px;
+    font-weight: var(--font-weight-regular);
+    line-height: var(--line-height-normal);
+    letter-spacing: var(--letter-spacing-normal);
+  }
+
+  [data-slot="switch-error"] {
+    color: var(--text-error);
+    font-family: var(--font-family-sans);
+    font-size: 12px;
+    font-weight: var(--font-weight-regular);
+    line-height: var(--line-height-normal);
+    letter-spacing: var(--letter-spacing-normal);
+  }
+
+  &:hover:not([data-disabled], [data-readonly]) [data-slot="switch-control"] {
+    border-color: var(--border-hover);
+    background-color: var(--surface-hover);
+  }
+
+  &:focus-within:not([data-readonly]) [data-slot="switch-control"] {
+    border-color: var(--border-focus);
+    box-shadow: 0 0 0 2px var(--surface-focus);
+  }
+
+  &[data-checked] [data-slot="switch-control"] {
+    box-sizing: border-box;
+    border-color: var(--icon-strong-base);
+    background-color: var(--icon-strong-base);
+  }
+
+  &[data-checked] [data-slot="switch-thumb"] {
+    border: none;
+    transform: translateX(12px);
+    background-color: var(--icon-invert-base);
+  }
+
+  &[data-checked]:hover:not([data-disabled], [data-readonly]) [data-slot="switch-control"] {
+    border-color: var(--border-hover);
+    background-color: var(--surface-hover);
+  }
+
+  &[data-disabled] {
+    cursor: not-allowed;
+  }
+
+  &[data-disabled] [data-slot="switch-control"] {
+    border-color: var(--border-disabled);
+    background-color: var(--surface-disabled);
+  }
+
+  &[data-disabled] [data-slot="switch-thumb"] {
+    background-color: var(--icon-disabled);
+  }
+
+  &[data-invalid] [data-slot="switch-control"] {
+    border-color: var(--border-error);
+  }
+
+  &[data-readonly] {
+    cursor: default;
+    pointer-events: none;
+  }
+}

+ 30 - 0
packages/ui/src/components/switch.tsx

@@ -0,0 +1,30 @@
+import { Switch as Kobalte } from "@kobalte/core/switch"
+import { children, Show, splitProps } from "solid-js"
+import type { ComponentProps, ParentProps } from "solid-js"
+
+export interface SwitchProps extends ParentProps<ComponentProps<typeof Kobalte>> {
+  hideLabel?: boolean
+  description?: string
+}
+
+export function Switch(props: SwitchProps) {
+  const [local, others] = splitProps(props, ["children", "class", "hideLabel", "description"])
+  const resolved = children(() => local.children)
+  return (
+    <Kobalte {...others} data-component="switch">
+      <Kobalte.Input data-slot="switch-input" />
+      <Show when={resolved()}>
+        <Kobalte.Label data-slot="switch-label" classList={{ "sr-only": local.hideLabel }}>
+          {resolved()}
+        </Kobalte.Label>
+      </Show>
+      <Show when={local.description}>
+        <Kobalte.Description data-slot="switch-description">{local.description}</Kobalte.Description>
+      </Show>
+      <Kobalte.ErrorMessage data-slot="switch-error" />
+      <Kobalte.Control data-slot="switch-control">
+        <Kobalte.Thumb data-slot="switch-thumb" />
+      </Kobalte.Control>
+    </Kobalte>
+  )
+}

+ 7 - 4
packages/ui/src/hooks/use-filtered-list.tsx

@@ -5,7 +5,7 @@ import { createStore } from "solid-js/store"
 import { createList } from "solid-list"
 
 export interface FilteredListProps<T> {
-  items: (filter: string) => T[] | Promise<T[]>
+  items: T[] | ((filter: string) => T[] | Promise<T[]>)
   key: (item: T) => string
   filterKeys?: string[]
   current?: T
@@ -19,10 +19,13 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
   const [store, setStore] = createStore<{ filter: string }>({ filter: "" })
 
   const [grouped, { refetch }] = createResource(
-    () => store.filter,
-    async (filter) => {
+    () => ({
+      filter: store.filter,
+      items: typeof props.items === "function" ? undefined : props.items,
+    }),
+    async ({ filter, items }) => {
       const needle = filter?.toLowerCase()
-      const all = (await props.items(needle)) || []
+      const all = (items ?? (await (props.items as (filter: string) => T[] | Promise<T[]>)(needle))) || []
       const result = pipe(
         all,
         (x) => {

+ 1 - 1
packages/ui/src/styles/index.css

@@ -30,8 +30,8 @@
 @import "../components/progress-circle.css" layer(components);
 @import "../components/resize-handle.css" layer(components);
 @import "../components/select.css" layer(components);
-@import "../components/select-dialog.css" layer(components);
 @import "../components/spinner.css" layer(components);
+@import "../components/switch.css" layer(components);
 @import "../components/session-review.css" layer(components);
 @import "../components/session-turn.css" layer(components);
 @import "../components/sticky-accordion-header.css" layer(components);