summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/desktop/src/components/dialog-connect.tsx2
-rw-r--r--packages/desktop/src/components/dialog-file-select.tsx52
-rw-r--r--packages/desktop/src/components/dialog-manage-models.tsx65
-rw-r--r--packages/desktop/src/components/dialog-model-unpaid.tsx133
-rw-r--r--packages/desktop/src/components/dialog-model.tsx275
-rw-r--r--packages/desktop/src/components/dialog-select-provider.tsx101
-rw-r--r--packages/desktop/src/components/prompt-input.tsx9
-rw-r--r--packages/desktop/src/context/local.tsx7
-rw-r--r--packages/desktop/src/pages/session.tsx38
-rw-r--r--packages/ui/src/components/dialog.css27
-rw-r--r--packages/ui/src/components/list.css38
-rw-r--r--packages/ui/src/components/list.tsx141
-rw-r--r--packages/ui/src/components/select-dialog.css44
-rw-r--r--packages/ui/src/components/select-dialog.tsx93
-rw-r--r--packages/ui/src/components/session-turn.css2
-rw-r--r--packages/ui/src/components/session-turn.tsx4
-rw-r--r--packages/ui/src/components/switch.css131
-rw-r--r--packages/ui/src/components/switch.tsx30
-rw-r--r--packages/ui/src/hooks/use-filtered-list.tsx11
-rw-r--r--packages/ui/src/styles/index.css2
20 files changed, 726 insertions, 479 deletions
diff --git a/packages/desktop/src/components/dialog-connect.tsx b/packages/desktop/src/components/dialog-connect.tsx
index d482b3f50..3a1e05f27 100644
--- a/packages/desktop/src/components/dialog-connect.tsx
+++ b/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)
}
diff --git a/packages/desktop/src/components/dialog-file-select.tsx b/packages/desktop/src/components/dialog-file-select.tsx
new file mode 100644
index 000000000..3afe06062
--- /dev/null
+++ b/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>
+ )
+}
diff --git a/packages/desktop/src/components/dialog-manage-models.tsx b/packages/desktop/src/components/dialog-manage-models.tsx
new file mode 100644
index 000000000..2904f9a5b
--- /dev/null
+++ b/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>
+ )
+}
diff --git a/packages/desktop/src/components/dialog-model-unpaid.tsx b/packages/desktop/src/components/dialog-model-unpaid.tsx
new file mode 100644
index 000000000..d218770d9
--- /dev/null
+++ b/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>
+ )
+}
diff --git a/packages/desktop/src/components/dialog-model.tsx b/packages/desktop/src/components/dialog-model.tsx
index 7f90e1a78..e8f9df055 100644
--- a/packages/desktop/src/components/dialog-model.tsx
+++ b/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>
)
}
diff --git a/packages/desktop/src/components/dialog-select-provider.tsx b/packages/desktop/src/components/dialog-select-provider.tsx
index 6dabdb8b4..1c54184bd 100644
--- a/packages/desktop/src/components/dialog-select-provider.tsx
+++ b/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>
)
}
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index ca0ccf96a..faecd9520 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/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" />
diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx
index f841da1cc..0970178ea 100644
--- a/packages/desktop/src/context/local.tsx
+++ b/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")
+ },
}
})()
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index 8ea9f87e1..c4adea000 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/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()}>
diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css
index 979906e26..fa5e1171e 100644
--- a/packages/ui/src/components/dialog.css
+++ b/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;
diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css
index 132824164..cd9e73d1d 100644
--- a/packages/ui/src/components/list.css
+++ b/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;
diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx
index 013767e60..2923956a9 100644
--- a/packages/ui/src/components/list.tsx
+++ b/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>
)
}
diff --git a/packages/ui/src/components/select-dialog.css b/packages/ui/src/components/select-dialog.css
deleted file mode 100644
index 9759174a6..000000000
--- a/packages/ui/src/components/select-dialog.css
+++ /dev/null
@@ -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"] {} */
-}
diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx
deleted file mode 100644
index 68707536a..000000000
--- a/packages/ui/src/components/select-dialog.tsx
+++ /dev/null
@@ -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>
- )
-}
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css
index bc61318e3..0f218b515 100644
--- a/packages/ui/src/components/session-turn.css
+++ b/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"] {
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index 196e0bdb6..ad2e6c36e 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/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)
})
diff --git a/packages/ui/src/components/switch.css b/packages/ui/src/components/switch.css
new file mode 100644
index 000000000..c01e45d5f
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/packages/ui/src/components/switch.tsx b/packages/ui/src/components/switch.tsx
new file mode 100644
index 000000000..af70dfb5c
--- /dev/null
+++ b/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>
+ )
+}
diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx
index e3b373d4d..76a5ae84f 100644
--- a/packages/ui/src/hooks/use-filtered-list.tsx
+++ b/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) => {
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index ba2c954bc..3f8838a7a 100644
--- a/packages/ui/src/styles/index.css
+++ b/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);