diff options
| author | Filip <[email protected]> | 2025-09-24 18:05:15 +0200 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-09-24 11:05:15 -0500 |
| commit | d3b6545e7c6069c9db031634b7890e6b8eb4de2a (patch) | |
| tree | 5457b28378cf453e2c138337fc1abee3b3423ed2 | |
| parent | 3f911b22b011af74426699dac079b33badf862b1 (diff) | |
| download | opencode-d3b6545e7c6069c9db031634b7890e6b8eb4de2a.tar.gz opencode-d3b6545e7c6069c9db031634b7890e6b8eb4de2a.zip | |
feat(app): added command palette (#2630)
Co-authored-by: Adam <[email protected]>
| -rw-r--r-- | packages/app/src/components/code.tsx | 2 | ||||
| -rw-r--r-- | packages/app/src/components/select-dialog.tsx | 216 | ||||
| -rw-r--r-- | packages/app/src/components/select.tsx | 99 | ||||
| -rw-r--r-- | packages/app/src/components/session-list.tsx | 2 | ||||
| -rw-r--r-- | packages/app/src/context/local.tsx | 54 | ||||
| -rw-r--r-- | packages/app/src/context/sync.tsx | 31 | ||||
| -rw-r--r-- | packages/app/src/index.css | 13 | ||||
| -rw-r--r-- | packages/app/src/pages/index.tsx | 92 | ||||
| -rw-r--r-- | packages/app/src/ui/button.tsx | 67 | ||||
| -rw-r--r-- | packages/app/src/ui/link.tsx | 8 | ||||
| -rw-r--r-- | packages/app/src/ui/tooltip.tsx | 2 |
11 files changed, 381 insertions, 205 deletions
diff --git a/packages/app/src/components/code.tsx b/packages/app/src/components/code.tsx index e6d5ba80d..e4121d121 100644 --- a/packages/app/src/components/code.tsx +++ b/packages/app/src/components/code.tsx @@ -279,7 +279,7 @@ export function Code(props: Props) { }} innerHTML={html()} class=" - font-mono text-xs tracking-wide overflow-y-auto no-scrollbar h-full + font-mono text-xs tracking-wide overflow-y-auto h-full [&]:[counter-reset:line] [&_pre]:focus-visible:outline-none [&_pre]:overflow-x-auto [&_pre]:no-scrollbar diff --git a/packages/app/src/components/select-dialog.tsx b/packages/app/src/components/select-dialog.tsx new file mode 100644 index 000000000..2ff64f5da --- /dev/null +++ b/packages/app/src/components/select-dialog.tsx @@ -0,0 +1,216 @@ +import { createEffect, Show, For, createMemo, type JSX } from "solid-js" +import { Dialog } from "@kobalte/core/dialog" +import { Icon, IconButton } from "@/ui" +import { createStore } from "solid-js/store" +import { entries, flatMap, groupBy, map, mapValues, pipe } from "remeda" +import { createList } from "solid-list" +import fuzzysort from "fuzzysort" + +interface SelectDialogProps<T> { + items: T[] + key: (item: T) => string + render: (item: T) => JSX.Element + current?: T + placeholder?: string + filter?: + | false + | { + keys: string[] + } + groupBy?: (x: T) => string + onSelect?: (value: T | undefined) => void + onClose?: () => void +} + +export function SelectDialog<T>(props: SelectDialogProps<T>) { + let scrollRef: HTMLDivElement | undefined + const [store, setStore] = createStore({ + filter: "", + mouseActive: false, + }) + + const grouped = createMemo(() => { + const needle = store.filter.toLowerCase() + const result = pipe( + props.items, + (x) => + !needle || !props.filter + ? x + : fuzzysort.go(needle, x, { keys: props.filter && props.filter.keys }).map((x) => x.obj), + groupBy((x) => (props.groupBy ? props.groupBy(x) : "")), + mapValues((x) => x.sort((a, b) => props.key(a).localeCompare(props.key(b)))), + entries(), + map(([k, v]) => ({ category: k, items: v })), + ) + return result + }) + const flat = createMemo(() => { + return pipe( + grouped(), + flatMap((x) => x.items), + ) + }) + const list = createList({ + items: () => flat().map(props.key), + initialActive: props.current ? props.key(props.current) : undefined, + loop: true, + }) + const resetSelection = () => list.setActive(props.key(flat()[0])) + + createEffect(() => { + store.filter + scrollRef?.scrollTo(0, 0) + resetSelection() + }) + + createEffect(() => { + if (store.mouseActive) return + if (list.active() === props.key(flat()[0])) { + scrollRef?.scrollTo(0, 0) + return + } + const element = scrollRef?.querySelector(`[data-key="${list.active()}"]`) + element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) + }) + + const handleInput = (value: string) => { + setStore("filter", value) + resetSelection() + } + + const handleSelect = (item: T) => { + props.onSelect?.(item) + props.onClose?.() + } + + const handleKey = (e: KeyboardEvent) => { + setStore("mouseActive", false) + + if (e.key === "Enter") { + e.preventDefault() + const selected = flat().find((x) => props.key(x) === list.active()) + if (selected) handleSelect(selected) + } else if (e.key === "Escape") { + e.preventDefault() + props.onClose?.() + } else { + list.onKeyDown(e) + } + } + + return ( + <Dialog defaultOpen modal onOpenChange={(open) => open || props.onClose?.()}> + <Dialog.Portal> + <Dialog.Overlay class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100]" /> + <Dialog.Content + class="fixed top-[20%] left-1/2 -translate-x-1/2 w-[90vw] max-w-2xl + shadow-[0_0_33px_rgba(0,0,0,0.8)] + bg-background border border-border-subtle/30 rounded-lg z-[101] + max-h-[60vh] flex flex-col" + > + <div class="border-b border-border-subtle/30"> + <div class="relative"> + <Icon name="command" size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted/80" /> + <input + type="text" + value={store.filter} + onInput={(e) => handleInput(e.currentTarget.value)} + onKeyDown={handleKey} + placeholder={props.placeholder} + class="w-full pl-10 pr-4 py-2 rounded-t-md + text-sm text-text placeholder-text-muted/70 + focus:outline-none" + autofocus + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + /> + <div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2"> + {/* <Show when={fileResults.loading && mode() === "files"}> + <div class="text-text-muted"> + <Icon name="refresh" size={14} class="animate-spin" /> + </div> + </Show> */} + <Show when={store.filter}> + <IconButton + size="xs" + variant="ghost" + class="text-text-muted hover:text-text" + onClick={() => { + setStore("filter", "") + resetSelection() + }} + > + <Icon name="close" size={14} /> + </IconButton> + </Show> + </div> + </div> + </div> + <div ref={(el) => (scrollRef = el)} class="relative flex-1 overflow-y-auto"> + <Show + when={flat().length > 0} + fallback={<div class="text-center py-8 text-text-muted text-sm">No results</div>} + > + <For each={grouped()}> + {(group) => ( + <> + <div class="top-0 sticky z-10 bg-background-panel p-2 text-xs text-text-muted/60 tracking-wider uppercase"> + {group.category} + </div> + <div class="p-2"> + <For each={group.items}> + {(item) => ( + <button + data-key={props.key(item)} + onClick={() => handleSelect(item)} + onMouseMove={() => { + setStore("mouseActive", true) + list.setActive(props.key(item)) + }} + classList={{ + "w-full px-3 py-2 flex items-center gap-3": true, + "rounded-md text-left transition-colors group": true, + "bg-background-element": props.key(item) === list.active(), + "hover:bg-background-element": true, + }} + > + {props.render(item)} + </button> + )} + </For> + </div> + </> + )} + </For> + </Show> + </div> + <div class="p-3 border-t border-border-subtle/30 flex items-center justify-between text-xs text-text-muted"> + <div class="flex items-center gap-5"> + <span class="flex items-center gap-1.5"> + <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]"> + ↑↓ + </kbd> + Navigate + </span> + <span class="flex items-center gap-1.5"> + <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]"> + ↵ + </kbd> + Select + </span> + <span class="flex items-center gap-1.5"> + <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]"> + ESC + </kbd> + Close + </span> + </div> + <span>{`${flat().length} results`}</span> + </div> + </Dialog.Content> + </Dialog.Portal> + </Dialog> + ) +} diff --git a/packages/app/src/components/select.tsx b/packages/app/src/components/select.tsx index a99eccbd8..3df8c9999 100644 --- a/packages/app/src/components/select.tsx +++ b/packages/app/src/components/select.tsx @@ -1,46 +1,26 @@ import { Select as KobalteSelect } from "@kobalte/core/select" -import { createEffect, createMemo, Show } from "solid-js" +import { createMemo } from "solid-js" import type { ComponentProps } from "solid-js" import { Icon } from "@/ui/icon" -import fuzzysort from "fuzzysort" import { pipe, groupBy, entries, map } from "remeda" -import { createStore } from "solid-js/store" +import { Button, type ButtonProps } from "@/ui" export interface SelectProps<T> { - variant?: "default" | "outline" - size?: "sm" | "md" | "lg" placeholder?: string - filter?: - | false - | { - placeholder?: string - keys: string[] - } options: T[] current?: T value?: (x: T) => string label?: (x: T) => string groupBy?: (x: T) => string - onFilter?: (query: string) => void onSelect?: (value: T | undefined) => void class?: ComponentProps<"div">["class"] classList?: ComponentProps<"div">["classList"] } -export function Select<T>(props: SelectProps<T>) { - let inputRef: HTMLInputElement | undefined = undefined - let listboxRef: HTMLUListElement | undefined = undefined - const [store, setStore] = createStore({ - filter: "", - }) +export function Select<T>(props: SelectProps<T> & ButtonProps) { const grouped = createMemo(() => { - const needle = store.filter.toLowerCase() const result = pipe( props.options, - (x) => - !needle || !props.filter - ? x - : fuzzysort.go(needle, x, { keys: props.filter && props.filter.keys }).map((x) => x.obj), groupBy((x) => (props.groupBy ? props.groupBy(x) : "")), // mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))), entries(), @@ -48,19 +28,6 @@ export function Select<T>(props: SelectProps<T>) { ) return result }) - // const flat = createMemo(() => { - // return pipe( - // grouped(), - // flatMap(({ options }) => options), - // ) - // }) - - createEffect(() => { - store.filter - listboxRef?.scrollTo(0, 0) - // setStore("selected", 0) - // scroll.scrollTo(0) - }) return ( <KobalteSelect<T, { category: string; options: T[] }> @@ -89,36 +56,21 @@ export function Select<T>(props: SelectProps<T>) { <KobalteSelect.ItemLabel> {props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)} </KobalteSelect.ItemLabel> - <KobalteSelect.ItemIndicator - classList={{ - "ml-auto": true, - }} - > + <KobalteSelect.ItemIndicator class="ml-auto"> <Icon name="checkmark" size={16} /> </KobalteSelect.ItemIndicator> </KobalteSelect.Item> )} onChange={(v) => { - if (props.onSelect) props.onSelect(v ?? undefined) - if (v !== null) { - // close the select - } + props.onSelect?.(v ?? undefined) }} - onOpenChange={(v) => v || setStore("filter", "")} > <KobalteSelect.Trigger + as={Button} + size={props.size || "sm"} + variant={props.variant || "secondary"} classList={{ ...(props.classList ?? {}), - "flex w-full items-center justify-between rounded-md transition-colors": true, - "focus-visible:outline-none focus-visible:ring focus-visible:ring-border-active/30": true, - "disabled:cursor-not-allowed disabled:opacity-50": true, - "data-[placeholder-shown]:text-text-muted cursor-pointer": true, - "hover:bg-background-element focus-visible:ring-border-active": true, - "bg-background-element text-text": props.variant === "default" || !props.variant, - "border-2 border-border bg-transparent text-text": props.variant === "outline", - "h-6 pl-2 text-xs": props.size === "sm", - "h-8 pl-3 text-sm": props.size === "md" || !props.size, - "h-10 pl-4 text-base": props.size === "lg", [props.class ?? ""]: !!props.class, }} > @@ -140,13 +92,6 @@ export function Select<T>(props: SelectProps<T>) { </KobalteSelect.Trigger> <KobalteSelect.Portal> <KobalteSelect.Content - onKeyDown={(e) => { - if (!props.filter) return - if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Escape") { - return - } - inputRef?.focus() - }} classList={{ "min-w-32 overflow-hidden rounded-md border border-border-subtle/40": true, "bg-background-panel p-1 shadow-md z-50": true, @@ -154,33 +99,7 @@ export function Select<T>(props: SelectProps<T>) { "data-[expanded]:animate-in data-[expanded]:fade-in-0 data-[expanded]:zoom-in-95": true, }} > - <Show when={props.filter}> - <input - ref={(el) => (inputRef = el)} - id="select-filter" - type="text" - placeholder={props.filter ? props.filter.placeholder : "Filter items"} - value={store.filter} - onInput={(e) => setStore("filter", e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Escape") { - e.preventDefault() - e.stopPropagation() - listboxRef?.focus() - } - }} - classList={{ - "w-full": true, - "px-2 pb-2 text-text font-light placeholder-text-muted/70 text-xs focus:outline-none": true, - }} - /> - </Show> - <KobalteSelect.Listbox - ref={(el) => (listboxRef = el)} - classList={{ - "overflow-y-auto max-h-48 no-scrollbar": true, - }} - /> + <KobalteSelect.Listbox class="overflow-y-auto max-h-48" /> </KobalteSelect.Content> </KobalteSelect.Portal> </KobalteSelect> diff --git a/packages/app/src/components/session-list.tsx b/packages/app/src/components/session-list.tsx index e57562586..e0819780d 100644 --- a/packages/app/src/components/session-list.tsx +++ b/packages/app/src/components/session-list.tsx @@ -7,7 +7,7 @@ export default function SessionList() { const local = useLocal() return ( - <VList data={sync.data.session} class="p-2 no-scrollbar"> + <VList data={sync.data.session} class="p-2"> {(session) => ( <Tooltip placement="right" value={session.title} class="w-full min-w-0"> <Button diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 825023616..c52fe0db4 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -1,7 +1,7 @@ import { createStore, produce, reconcile } from "solid-js/store" import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js" import { uniqueBy } from "remeda" -import type { FileContent, FileNode } from "@opencode-ai/sdk" +import type { FileContent, FileNode, Model, Provider } from "@opencode-ai/sdk" import { useSDK, useEvent, useSync } from "@/context" export type LocalFile = FileNode & @@ -19,12 +19,17 @@ export type LocalFile = FileNode & export type TextSelection = LocalFile["selection"] export type View = LocalFile["view"] +export type LocalModel = Omit<Model, "provider"> & { + provider: Provider +} +export type ModelKey = { providerID: string; modelID: string } + function init() { const sdk = useSDK() const sync = useSync() - const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent")) const agent = (() => { + const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent")) const [store, setStore] = createStore<{ current: string }>({ @@ -54,18 +59,14 @@ function init() { })() const model = (() => { + const list = createMemo(() => + sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)), + ) + const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID) + const [store, setStore] = createStore<{ - model: Record< - string, - { - providerID: string - modelID: string - } - > - recent: { - providerID: string - modelID: string - }[] + model: Record<string, ModelKey> + recent: ModelKey[] }>({ model: {}, recent: [], @@ -81,37 +82,21 @@ function init() { if (store.recent.length) return store.recent[0] const provider = sync.data.provider[0] const model = Object.values(provider.models)[0] - return { - providerID: provider.id, - modelID: model.id, - } + return { modelID: model.id, providerID: provider.id } }) const current = createMemo(() => { const a = agent.current() - return store.model[agent.current().name] ?? (a.model ? a.model : fallback()) + return find(store.model[agent.current().name]) ?? find(a.model ?? fallback()) }) - const list = createMemo(() => - sync.data.provider.flatMap((x) => Object.values(x.models).map((m) => ({ providerID: x.id, modelID: m.id }))), - ) + const recent = createMemo(() => store.recent.map(find).filter(Boolean)) return { list, current, - recent() { - return store.recent - }, - parsed: createMemo(() => { - const value = current() - const provider = sync.data.provider.find((x) => x.id === value.providerID)! - const model = provider.models[value.modelID] - return { - provider: provider.name ?? value.providerID, - model: model.name ?? value.modelID, - } - }), - set(model: { providerID: string; modelID: string } | undefined, options?: { recent?: boolean }) { + recent, + set(model: ModelKey | undefined, options?: { recent?: boolean }) { batch(() => { setStore("model", agent.current().name, model ?? fallback()) if (options?.recent && model) { @@ -234,6 +219,7 @@ function init() { break case "file.watcher.updated": load(event.properties.file) + sync.load.changes() break } }) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 907071d75..a03b8b58a 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -94,20 +94,24 @@ function init() { }) const sdk = useSDK() - Promise.all([ - sdk.config.providers().then((x) => setStore("provider", x.data!.providers)), - sdk.path.get().then((x) => setStore("path", x.data!)), - sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), - sdk.session.list().then((x) => - setStore( - "session", - (x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)), + + const load = { + provider: () => sdk.config.providers().then((x) => setStore("provider", x.data!.providers)), + path: () => sdk.path.get().then((x) => setStore("path", x.data!)), + agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), + session: () => + sdk.session.list().then((x) => + setStore( + "session", + (x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)), + ), ), - ), - sdk.config.get().then((x) => setStore("config", x.data!)), - sdk.file.status().then((x) => setStore("changes", x.data!)), - sdk.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)), - ]).then(() => setStore("ready", true)) + config: () => sdk.config.get().then((x) => setStore("config", x.data!)), + changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)), + node: () => sdk.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)), + } + + Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true)) return { data: store, @@ -138,6 +142,7 @@ function init() { ) }, }, + load, } } diff --git a/packages/app/src/index.css b/packages/app/src/index.css index aebd52be6..cac2e91a6 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -19,6 +19,19 @@ /* color: var(--color-background); */ } + ::-webkit-scrollbar-track { + background: var(--theme-background-panel); + } + + ::-webkit-scrollbar-thumb { + background-color: var(--theme-border-subtle); + border-radius: 6px; + } + + * { + scrollbar-color: var(--theme-border-subtle) var(--theme-background-panel); + } + .prose h1 { color: var(--color-text); font-size: var(--text-sm); diff --git a/packages/app/src/pages/index.tsx b/packages/app/src/pages/index.tsx index 50c729789..9133f40d8 100644 --- a/packages/app/src/pages/index.tsx +++ b/packages/app/src/pages/index.tsx @@ -1,8 +1,9 @@ -import { FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui" +import { Button, FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui" import { Tabs } from "@/ui/tabs" import { Select } from "@/components/select" import FileTree from "@/components/file-tree" import { For, Match, onCleanup, onMount, Show, Switch } from "solid-js" +import { SelectDialog } from "@/components/select-dialog" import { useLocal, useSDK } from "@/context" import { Code } from "@/components/code" import { @@ -28,6 +29,7 @@ export default function Page() { activeItem: undefined as string | undefined, prompt: "", dragging: undefined as "left" | "right" | undefined, + modelSelectOpen: false, }) let inputRef: HTMLInputElement | undefined = undefined @@ -43,6 +45,17 @@ export default function Page() { }) const handleKeyDown = (e: KeyboardEvent) => { + if (e.getModifierState(MOD) && e.shiftKey && e.key.toLowerCase() === "p") { + e.preventDefault() + setStore("modelSelectOpen", true) + return + } + if (e.getModifierState(MOD) && e.key.toLowerCase() === "p") { + e.preventDefault() + setStore("modelSelectOpen", true) + return + } + const inputFocused = document.activeElement === inputRef if (inputFocused) { if (e.key === "Escape") { @@ -190,7 +203,7 @@ export default function Page() { path: { id: session!.id }, body: { agent: local.agent.current()!.name, - model: local.model.current(), + model: { modelID: local.model.current()!.id, providerID: local.model.current()!.provider.id }, parts: [ { type: "text", @@ -265,7 +278,7 @@ export default function Page() { class="fixed top-0 right-0 h-full border-l border-border-subtle/30 flex flex-col overflow-hidden" style={`width: ${local.layout.rightWidth()}px`} > - <div class="relative flex-1 min-h-0 overflow-y-auto no-scrollbar"> + <div class="relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden"> <Show when={local.session.active()} fallback={<SessionList />}> {(activeSession) => ( <div class="relative"> @@ -470,7 +483,7 @@ export default function Page() { type="text" value={store.prompt} onInput={(e) => setStore("prompt", e.currentTarget.value)} - placeholder="It all starts with a prompt..." + placeholder="Placeholder text..." class="w-full p-1 pb-4 text-text font-light placeholder-text-muted/70 text-sm focus:outline-none" /> <div class="flex justify-between items-center text-xs text-text-muted"> @@ -479,24 +492,13 @@ export default function Page() { options={local.agent.list().map((a) => a.name)} current={local.agent.current().name} onSelect={local.agent.set} - size="sm" class="uppercase" /> - <Select - options={local.model.list()} - current={local.model.current()} - onSelect={local.model.set} - label={(x) => x.modelID} - value={(x) => `${x.providerID}.${x.modelID}`} - filter={{ - keys: ["providerID", "modelID"], - placeholder: "Filter models", - }} - groupBy={(x) => x.providerID} - size="sm" - class="uppercase" - /> - <span class="text-text-muted/70">{local.model.parsed().provider}</span> + <Button onClick={() => setStore("modelSelectOpen", true)}> + {local.model.current()?.name ?? "Select model"} + <Icon name="chevron-down" size={24} class="text-text-muted" /> + </Button> + <span class="text-text-muted/70 whitespace-nowrap">{local.model.current()?.provider.name}</span> </div> <div class="flex gap-1 items-center"> <IconButton class="text-text-muted" size="xs" variant="ghost"> @@ -510,6 +512,56 @@ export default function Page() { </div> </form> </div> + <Show when={store.modelSelectOpen}> + <SelectDialog + key={(x) => `${x.provider.id}:${x.id}`} + items={local.model.list()} + current={local.model.current()} + render={(i) => ( + <div class="w-full flex items-center justify-between"> + <div class="flex items-center gap-x-2 text-text-muted grow min-w-0"> + <img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-4 invert opacity-40" /> + <span class="text-xs text-text whitespace-nowrap">{i.name}</span> + <span class="text-xs text-text-muted/80 whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0"> + {i.id} + </span> + </div> + <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"> + <Tooltip forceMount={false} value="Reasoning"> + <Icon name="brain" size={16} classList={{ "text-accent": i.reasoning }} /> + </Tooltip> + <Tooltip forceMount={false} value="Tools"> + <Icon name="hammer" size={16} classList={{ "text-secondary": i.tool_call }} /> + </Tooltip> + <Tooltip forceMount={false} value="Attachments"> + <Icon name="photo" size={16} classList={{ "text-success": i.attachment }} /> + </Tooltip> + <div class="rounded-full bg-text-muted/20 text-text-muted/80 w-9 h-4 flex items-center justify-center text-[10px]"> + {new Intl.NumberFormat("en-US", { + notation: "compact", + compactDisplay: "short", + }).format(i.limit.context)} + </div> + <Tooltip forceMount={false} value={`$${i.cost?.input}/1M input, $${i.cost?.output}/1M output`}> + <div class="rounded-full bg-success/20 text-success/80 w-9 h-4 flex items-center justify-center text-[10px]"> + <Switch fallback="FREE"> + <Match when={i.cost?.input > 10}>$$$</Match> + <Match when={i.cost?.input > 1}>$$</Match> + <Match when={i.cost?.input > 0.1}>$</Match> + </Switch> + </div> + </Tooltip> + </div> + </div> + )} + filter={{ + keys: ["provider.name", "name", "id"], + }} + groupBy={(x) => x.provider.name} + onClose={() => setStore("modelSelectOpen", false)} + onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)} + /> + </Show> </div> ) } diff --git a/packages/app/src/ui/button.tsx b/packages/app/src/ui/button.tsx index e496d41bb..21d89fbee 100644 --- a/packages/app/src/ui/button.tsx +++ b/packages/app/src/ui/button.tsx @@ -1,49 +1,36 @@ -import { Button as KobalteButton } from "@kobalte/core/button" -import { splitProps } from "solid-js" -import type { ComponentProps } from "solid-js" +import { Button as Kobalte } from "@kobalte/core/button" +import { type ComponentProps, splitProps } from "solid-js" -export interface ButtonProps extends ComponentProps<typeof KobalteButton> { - variant?: "primary" | "secondary" | "outline" | "ghost" +export interface ButtonProps { + variant?: "primary" | "secondary" | "ghost" size?: "sm" | "md" | "lg" } -export const buttonStyles = { - base: "inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer", - variants: { - primary: "bg-primary text-background hover:bg-secondary focus-visible:ring-primary data-[disabled]:opacity-50", - secondary: - "bg-background-panel text-text hover:bg-background-element focus-visible:ring-secondary data-[disabled]:opacity-50", - outline: - "border border-border bg-transparent text-text hover:bg-background-panel focus-visible:ring-border-active data-[disabled]:border-border-subtle data-[disabled]:text-text-muted", - ghost: "text-text hover:bg-background-panel focus-visible:ring-border-active data-[disabled]:text-text-muted", - }, - sizes: { - sm: "h-8 px-3 text-sm", - md: "h-10 px-4 text-sm", - lg: "h-12 px-6 text-base", - }, -} - -export function getButtonClasses( - variant: keyof typeof buttonStyles.variants = "primary", - size: keyof typeof buttonStyles.sizes = "md", - className?: string, -) { - return `${buttonStyles.base} ${buttonStyles.variants[variant]} ${buttonStyles.sizes[size]}${className ? ` ${className}` : ""}` -} - -export function Button(props: ButtonProps) { - const [local, others] = splitProps(props, ["variant", "size", "class", "classList"]) +export function Button(props: ComponentProps<"button"> & ButtonProps) { + const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"]) return ( - <KobalteButton + <Kobalte + {...rest} + data-size={split.size || "sm"} + data-variant={split.variant || "secondary"} + class="inline-flex items-center justify-center rounded-md cursor-pointer font-medium transition-colors + min-w-0 whitespace-nowrap truncate + data-[size=sm]:h-6 data-[size=sm]:pl-2 data-[size=sm]:text-xs + data-[size=md]:h-8 data-[size=md]:pl-3 data-[size=md]:text-sm + data-[size=lg]:h-10 data-[size=lg]:pl-4 data-[size=lg]:text-base + data-[variant=primary]:bg-primary data-[variant=primary]:text-background + data-[variant=primary]:hover:bg-secondary data-[variant=primary]:focus-visible:ring-primary + data-[variant=secondary]:bg-background-element data-[variant=secondary]:text-text + data-[variant=secondary]:hover:bg-background-element data-[variant=secondary]:focus-visible:ring-secondary + data-[variant=ghost]:text-text data-[variant=ghost]:hover:bg-background-panel data-[variant=ghost]:focus-visible:ring-border-active + focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-transparent + disabled:pointer-events-none disabled:opacity-50" classList={{ - ...(local.classList ?? {}), - [buttonStyles.base]: true, - [buttonStyles.variants[local.variant || "primary"]]: true, - [buttonStyles.sizes[local.size || "md"]]: true, - [local.class ?? ""]: !!local.class, + ...(split.classList ?? {}), + [split.class ?? ""]: !!split.class, }} - {...others} - /> + > + {props.children} + </Kobalte> ) } diff --git a/packages/app/src/ui/link.tsx b/packages/app/src/ui/link.tsx index a75a059ec..461206d58 100644 --- a/packages/app/src/ui/link.tsx +++ b/packages/app/src/ui/link.tsx @@ -1,15 +1,13 @@ import { A } from "@solidjs/router" import { splitProps } from "solid-js" import type { ComponentProps } from "solid-js" -import { getButtonClasses } from "./button" export interface LinkProps extends ComponentProps<typeof A> { - variant?: "primary" | "secondary" | "outline" | "ghost" + variant?: "primary" | "secondary" | "ghost" size?: "sm" | "md" | "lg" } export function Link(props: LinkProps) { - const [local, others] = splitProps(props, ["variant", "size", "class"]) - const classes = local.variant ? getButtonClasses(local.variant, local.size, local.class) : local.class - return <A class={classes} {...others} /> + const [, others] = splitProps(props, ["variant", "size", "class"]) + return <A {...others} /> } diff --git a/packages/app/src/ui/tooltip.tsx b/packages/app/src/ui/tooltip.tsx index f5884ca80..db826b77a 100644 --- a/packages/app/src/ui/tooltip.tsx +++ b/packages/app/src/ui/tooltip.tsx @@ -34,7 +34,7 @@ export function Tooltip(props: TooltipProps) { <KobalteTooltip.Portal> <KobalteTooltip.Content classList={{ - "z-50 max-w-[320px] rounded-md bg-background-element px-2 py-1": true, + "z-[1000] max-w-[320px] rounded-md bg-background-element px-2 py-1": true, "text-xs font-medium text-text shadow-md pointer-events-none!": true, "transition-all duration-150 ease-out": true, "transform-gpu transform-origin-[var(--kb-tooltip-content-transform-origin)]": true, |
