diff options
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">"{filter()}"</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">"{filter()}"</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); |
