- Plan and build anything
+
+
+
+
+ {(i) => (
+
+
+
+
+
+ {getDirectory(i)}/
+
+ {getFilename(i)}
+
+
+
+
+ )}
+
+
+
+
)
}
@@ -223,7 +367,7 @@ function isEqual(arrA: ContentPart[], arrB: ContentPart[]): boolean {
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
return false
}
- if (partA.type === "attachment" && partA.fileId !== (partB as AttachmentPart).fileId) {
+ if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
return false
}
}
@@ -241,24 +385,48 @@ function getCursorPosition(parent: HTMLElement): number {
}
function setCursorPosition(parent: HTMLElement, position: number) {
- let child = parent.firstChild
- let offset = position
- while (child) {
- if (offset > child.textContent!.length) {
- offset -= child.textContent!.length
- child = child.nextSibling
- } else {
- try {
- const range = document.createRange()
- const sel = window.getSelection()
- range.setStart(child, offset)
- range.collapse(true)
- sel?.removeAllRanges()
- sel?.addRange(range)
- } catch (e) {
- console.error("Failed to set cursor position.", e)
- }
+ let remaining = position
+ let node = parent.firstChild
+ while (node) {
+ const length = node.textContent ? node.textContent.length : 0
+ const isText = node.nodeType === Node.TEXT_NODE
+ const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
+
+ if (isText && remaining <= length) {
+ const range = document.createRange()
+ const selection = window.getSelection()
+ range.setStart(node, remaining)
+ range.collapse(true)
+ selection?.removeAllRanges()
+ selection?.addRange(range)
return
}
+
+ if (isFile && remaining <= length) {
+ const range = document.createRange()
+ const selection = window.getSelection()
+ range.setStartAfter(node)
+ range.collapse(true)
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ return
+ }
+
+ remaining -= length
+ node = node.nextSibling
}
+
+ const fallbackRange = document.createRange()
+ const fallbackSelection = window.getSelection()
+ const last = parent.lastChild
+ if (last && last.nodeType === Node.TEXT_NODE) {
+ const len = last.textContent ? last.textContent.length : 0
+ fallbackRange.setStart(last, len)
+ }
+ if (!last || last.nodeType !== Node.TEXT_NODE) {
+ fallbackRange.selectNodeContents(parent)
+ }
+ fallbackRange.collapse(false)
+ fallbackSelection?.removeAllRanges()
+ fallbackSelection?.addRange(fallbackRange)
}
diff --git a/packages/desktop/src/components/select-dialog.tsx b/packages/desktop/src/components/select-dialog.tsx
deleted file mode 100644
index bf9aa0db..00000000
--- a/packages/desktop/src/components/select-dialog.tsx
+++ /dev/null
@@ -1,226 +0,0 @@
-import { createEffect, Show, For, createMemo, type JSX, createResource } from "solid-js"
-import { Dialog } from "@kobalte/core/dialog"
-import { Icon } from "@opencode-ai/ui"
-import { IconButton } from "@/ui"
-import { createStore } from "solid-js/store"
-import { entries, flatMap, groupBy, map, pipe } from "remeda"
-import { createList } from "solid-list"
-import fuzzysort from "fuzzysort"
-
-interface SelectDialogProps
{
- items: T[] | ((filter: string) => Promise)
- key: (item: T) => string
- render: (item: T) => JSX.Element
- filter?: string[]
- current?: T
- placeholder?: string
- groupBy?: (x: T) => string
- onSelect?: (value: T | undefined) => void
- onClose?: () => void
-}
-
-export function SelectDialog(props: SelectDialogProps) {
- let scrollRef: HTMLDivElement | undefined
- const [store, setStore] = createStore({
- filter: "",
- mouseActive: false,
- })
-
- const [grouped] = createResource(
- () => store.filter,
- async (filter) => {
- const needle = filter.toLowerCase()
- const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || []
- const result = pipe(
- all,
- (x) => {
- if (!needle) return x
- if (!props.filter && Array.isArray(x) && x.every((e) => typeof e === "string")) {
- return fuzzysort.go(needle, x).map((x) => x.target) as T[]
- }
- return fuzzysort.go(needle, x, { keys: props.filter! }).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 = () => {
- const all = flat()
- if (all.length === 0) return
- list.setActive(props.key(all[0]))
- }
-
- createEffect(() => {
- store.filter
- scrollRef?.scrollTo(0, 0)
- resetSelection()
- })
-
- createEffect(() => {
- const all = flat()
- if (store.mouseActive || all.length === 0) return
- if (list.active() === props.key(all[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 (
-
- )
-}
diff --git a/packages/desktop/src/components/session-timeline.tsx b/packages/desktop/src/components/session-timeline.tsx
index 2474b310..0d8a7cd3 100644
--- a/packages/desktop/src/components/session-timeline.tsx
+++ b/packages/desktop/src/components/session-timeline.tsx
@@ -1,11 +1,10 @@
import { useLocal, useSync } from "@/context"
import { Icon, Tooltip } from "@opencode-ai/ui"
import { Collapsible } from "@/ui"
-import type { AssistantMessage, Part, ToolPart } from "@opencode-ai/sdk"
+import type { AssistantMessage, Message, Part, ToolPart } from "@opencode-ai/sdk"
import { DateTime } from "luxon"
import {
createSignal,
- onMount,
For,
Match,
splitProps,
@@ -67,7 +66,7 @@ function ReadToolPart(props: { part: ToolPart }) {
{(state) => {
const path = state().input["filePath"] as string
return (
- local.file.open(path)}>
+ local.file.open(path)}>
Read {getFilename(path)}
)
@@ -253,7 +252,7 @@ export default function SessionTimeline(props: { session: string; class?: string
case "patch":
return false
case "text":
- return !part.synthetic
+ return !part.synthetic && part.text.trim()
case "reasoning":
return part.text.trim()
case "tool":
@@ -270,8 +269,17 @@ export default function SessionTimeline(props: { session: string; class?: string
}
}
+ const hasValidParts = (message: Message) => {
+ return sync.data.part[message.id]?.filter(valid).length > 0
+ }
+
+ const hasTextPart = (message: Message) => {
+ return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text")
+ }
+
const session = createMemo(() => sync.session.get(props.session))
const messages = createMemo(() => sync.data.message[props.session] ?? [])
+ const messagesWithValidParts = createMemo(() => sync.data.message[props.session]?.filter(hasValidParts) ?? [])
const working = createMemo(() => {
const last = messages()[messages().length - 1]
if (!last) return false
@@ -386,7 +394,7 @@ export default function SessionTimeline(props: { session: string; class?: string
[props.class ?? ""]: !!props.class,
}}
>
-
+
@@ -397,11 +405,16 @@ export default function SessionTimeline(props: { session: string; class?: string
{cost()}
-
-
+
+
{(message) => (
-
-
+
+
{(part) => (
-
{part.type}
}>
@@ -449,9 +462,9 @@ export default function SessionTimeline(props: { session: string; class?: string
-
+
Raw Session Data
-
+
@@ -460,9 +473,9 @@ export default function SessionTimeline(props: { session: string; class?: string
-
+
session
-
+
@@ -477,9 +490,9 @@ export default function SessionTimeline(props: { session: string; class?: string
-
+
{message.role === "user" ? "user" : "assistant"}
-
+
@@ -493,9 +506,9 @@ export default function SessionTimeline(props: { session: string; class?: string
-
+
{part.type}
-
+
diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx
index 80473d84..58d47911 100644
--- a/packages/desktop/src/pages/index.tsx
+++ b/packages/desktop/src/pages/index.tsx
@@ -1,16 +1,14 @@
-import { Button, Icon, List, Tooltip } from "@opencode-ai/ui"
-import { FileIcon, IconButton } from "@/ui"
+import { Button, Icon, List, SelectDialog, Tooltip } from "@opencode-ai/ui"
+import { FileIcon } from "@/ui"
import FileTree from "@/components/file-tree"
import EditorPane from "@/components/editor-pane"
-import { For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
-import { SelectDialog } from "@/components/select-dialog"
+import { For, onCleanup, onMount, Show } from "solid-js"
import { useSync, useSDK, useLocal } from "@/context"
import type { LocalFile, TextSelection } from "@/context/local"
import SessionTimeline from "@/components/session-timeline"
-import { type PromptContentPart, type PromptSubmitValue } from "@/components/prompt-form"
import { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils"
-import { PromptInput } from "@/components/prompt-input"
+import { ContentPart, PromptInput } from "@/components/prompt-input"
import { DateTime } from "luxon"
export default function Page() {
@@ -22,8 +20,7 @@ export default function Page() {
modelSelectOpen: false,
fileSelectOpen: false,
})
-
- let inputRef: HTMLTextAreaElement | undefined = undefined
+ let inputRef!: HTMLDivElement
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
@@ -50,7 +47,7 @@ export default function Page() {
const focused = document.activeElement === inputRef
if (focused) {
if (event.key === "Escape") {
- // inputRef?.blur()
+ inputRef?.blur()
}
return
}
@@ -77,7 +74,7 @@ export default function Page() {
}
if (event.key.length === 1 && event.key !== "Unidentified") {
- // inputRef?.focus()
+ inputRef?.focus()
}
}
@@ -104,9 +101,7 @@ export default function Page() {
}
}
- const handlePromptSubmit2 = () => {}
-
- const handlePromptSubmit = async (prompt: PromptSubmitValue) => {
+ const handlePromptSubmit = async (parts: ContentPart[]) => {
const existingSession = local.session.active()
let session = existingSession
if (!session) {
@@ -134,6 +129,7 @@ export default function Page() {
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
+ const text = parts.map((part) => part.content).join("")
const attachments = new Map()
const registerAttachment = (path: string, selection: TextSelection | undefined, label?: string) => {
@@ -147,30 +143,27 @@ export default function Page() {
})
}
- const promptAttachments = prompt.parts.filter(
- (part): part is Extract => part.kind === "attachment",
- )
-
+ const promptAttachments = parts.filter((part) => part.type === "file")
for (const part of promptAttachments) {
- registerAttachment(part.path, part.selection, part.display)
+ registerAttachment(part.path, part.selection, part.content)
}
- const activeFile = local.context.active()
- if (activeFile) {
- registerAttachment(
- activeFile.path,
- activeFile.selection,
- activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
- )
- }
+ // const activeFile = local.context.active()
+ // if (activeFile) {
+ // registerAttachment(
+ // activeFile.path,
+ // activeFile.selection,
+ // activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
+ // )
+ // }
- for (const contextFile of local.context.all()) {
- registerAttachment(
- contextFile.path,
- contextFile.selection,
- formatAttachmentLabel(contextFile.path, contextFile.selection),
- )
- }
+ // for (const contextFile of local.context.all()) {
+ // registerAttachment(
+ // contextFile.path,
+ // contextFile.selection,
+ // formatAttachmentLabel(contextFile.path, contextFile.selection),
+ // )
+ // }
const attachmentParts = Array.from(attachments.values()).map((attachment) => {
const absolute = toAbsolutePath(attachment.path)
@@ -205,7 +198,7 @@ export default function Page() {
parts: [
{
type: "text",
- text: prompt.text,
+ text,
},
...attachmentParts,
],
@@ -213,16 +206,10 @@ export default function Page() {
})
}
- const plus = (
- setStore("fileSelectOpen", true)}
- >
-
-
- )
+ const handleNewSession = () => {
+ local.session.setActive(undefined)
+ inputRef?.focus()
+ }
return (
@@ -234,7 +221,8 @@ export default function Page() {
-
@@ -268,25 +256,30 @@ export default function Page() {
-
+
{(activeSession) => }
-
+
+
+
-
-
- {/*
setStore("modelSelectOpen", true)} */}
- {/* onInputRefChange={(element: HTMLTextAreaElement | undefined) => { */}
- {/* inputRef = element ?? undefined */}
- {/* }} */}
- {/* /> */}
+
+
{
+ inputRef = el
+ }}
+ onSubmit={handlePromptSubmit}
+ />
@@ -302,7 +295,7 @@ export default function Page() {
-
local.file.open(path, { view: "diff-unified", pinned: true })}
- class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 cursor-pointer hover:bg-background-element"
+ class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element"
>
{getFilename(path)}
@@ -318,59 +311,16 @@ export default function Page() {
-
- `${x.provider.id}:${x.id}`}
- items={local.model.list()}
- current={local.model.current()}
- render={(i) => (
-
-
-

-
{i.name}
-
- {i.id}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {new Intl.NumberFormat("en-US", {
- notation: "compact",
- compactDisplay: "short",
- }).format(i.limit.context)}
-
-
-
-
- 10}>$$$
- 1}>$$
- 0.1}>$
-
-
-
-
-
- )}
- filter={["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)}
- />
-
x}
- render={(i) => (
+ onOpenChange={(open) => setStore("fileSelectOpen", open)}
+ onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
+ >
+ {(i) => (
@@ -382,9 +332,7 @@ export default function Page() {
)}
- onClose={() => setStore("fileSelectOpen", false)}
- onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)}
- />
+
)
diff --git a/packages/desktop/src/ui/collapsible.tsx b/packages/desktop/src/ui/collapsible.tsx
index d17b3e62..fbc6fcbf 100644
--- a/packages/desktop/src/ui/collapsible.tsx
+++ b/packages/desktop/src/ui/collapsible.tsx
@@ -16,7 +16,7 @@ function CollapsibleTrigger(props: CollapsibleTriggerProps) {
return (
{
- variant?: "primary" | "secondary" | "outline" | "ghost"
- size?: "xs" | "sm" | "md" | "lg"
- children: JSX.Element
-}
-
-export function IconButton(props: IconButtonProps) {
- const [local, others] = splitProps(props, ["variant", "size", "class", "classList"])
- return (
-
- )
-}
diff --git a/packages/desktop/src/ui/index.ts b/packages/desktop/src/ui/index.ts
index a6ade6ff..e273e8ef 100644
--- a/packages/desktop/src/ui/index.ts
+++ b/packages/desktop/src/ui/index.ts
@@ -5,4 +5,3 @@ export {
type CollapsibleContentProps,
} from "./collapsible"
export { FileIcon, type FileIconProps } from "./file-icon"
-export { IconButton, type IconButtonProps } from "./icon-button"
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 4419b1f2..c32bfb7e 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -5,6 +5,7 @@
"exports": {
".": "./src/components/index.ts",
"./*": "./src/components/*.tsx",
+ "./hooks": "./src/hooks/index.ts",
"./styles": "./src/styles/index.css",
"./styles/tailwind": "./src/styles/tailwind/index.css",
"./fonts/*": "./src/assets/fonts/*"
@@ -23,11 +24,13 @@
},
"dependencies": {
"@kobalte/core": "catalog:",
+ "@pierre/precision-diffs": "0.0.2-alpha.1-1",
"@solidjs/meta": "catalog:",
- "remeda": "catalog:",
+ "fuzzysort": "catalog:",
"luxon": "catalog:",
- "virtua": "catalog:",
+ "remeda": "catalog:",
"solid-js": "catalog:",
- "solid-list": "catalog:"
+ "solid-list": "catalog:",
+ "virtua": "catalog:"
}
}
diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css
index c9ccf4ec..7bf09685 100644
--- a/packages/ui/src/components/button.css
+++ b/packages/ui/src/components/button.css
@@ -1,5 +1,4 @@
[data-component="button"] {
- cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -32,12 +31,7 @@
border-color: var(--border-weak-base);
background-color: var(--button-secondary-base);
color: var(--text-strong);
-
- /* 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);
+ box-shadow: var(--shadow-xs);
&:hover:not(:disabled) {
border-color: var(--border-hover);
@@ -84,12 +78,11 @@
padding: 0 8px 0 6px;
gap: 8px;
- /* text-12-medium */
font-family: var(--font-family-sans);
- font-size: var(--font-size-small);
+ font-size: var(--font-size-base);
font-style: normal;
font-weight: var(--font-weight-medium);
- line-height: var(--line-height-large); /* 166.667% */
+ line-height: var(--line-height-large); /* 171.429% */
letter-spacing: var(--letter-spacing-normal);
}
diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx
index db1da2fb..cae65843 100644
--- a/packages/ui/src/components/button.tsx
+++ b/packages/ui/src/components/button.tsx
@@ -1,12 +1,14 @@
import { Button as Kobalte } from "@kobalte/core/button"
import { type ComponentProps, splitProps } from "solid-js"
-export interface ButtonProps {
+export interface ButtonProps
+ extends ComponentProps,
+ Pick, "class" | "classList" | "children"> {
size?: "normal" | "large"
variant?: "primary" | "secondary" | "ghost"
}
-export function Button(props: ComponentProps<"button"> & ButtonProps) {
+export function Button(props: ButtonProps) {
const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"])
return (
["class"]
+ classList?: ComponentProps<"div">["classList"]
+}
+
+export function DialogRoot(props: DialogProps) {
+ let trigger!: HTMLElement
+ const [local, others] = splitProps(props, ["trigger", "class", "classList", "children"])
+
+ const resetTabIndex = () => {
+ trigger.tabIndex = 0
+ }
+
+ const handleTriggerFocus = (e: FocusEvent & { currentTarget: HTMLElement | null }) => {
+ const firstChild = e.currentTarget?.firstElementChild as HTMLElement
+ if (!firstChild) return
+
+ firstChild.focus()
+ trigger.tabIndex = -1
+
+ firstChild.addEventListener("focusout", resetTabIndex)
+ onCleanup(() => {
+ firstChild.removeEventListener("focusout", resetTabIndex)
+ })
+ }
+
+ return (
+
+
+
+ {props.trigger}
+
+
+
+
+
+
+
+ {local.children}
+
+
+
+
+
+ )
+}
+
+function DialogHeader(props: ComponentProps<"div">) {
+ return
+}
+
+function DialogBody(props: ComponentProps<"div">) {
+ return
+}
+
+function DialogTitle(props: DialogTitleProps & ComponentProps<"h2">) {
+ return
+}
+
+function DialogDescription(props: DialogDescriptionProps & ComponentProps<"p">) {
+ return
+}
+
+function DialogCloseButton(props: DialogCloseButtonProps & ComponentProps<"button">) {
+ return
+}
+
+export const Dialog = Object.assign(DialogRoot, {
+ Header: DialogHeader,
+ Title: DialogTitle,
+ Description: DialogDescription,
+ CloseButton: DialogCloseButton,
+ Body: DialogBody,
+})
diff --git a/packages/ui/src/components/icon-button.css b/packages/ui/src/components/icon-button.css
new file mode 100644
index 00000000..6fe95fcc
--- /dev/null
+++ b/packages/ui/src/components/icon-button.css
@@ -0,0 +1,117 @@
+[data-component="icon-button"] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 100%;
+ text-decoration: none;
+ user-select: none;
+ aspect-ratio: 1;
+
+ &:disabled {
+ background-color: var(--icon-strong-disabled);
+ color: var(--icon-invert-base);
+ cursor: not-allowed;
+ }
+
+ &:focus {
+ outline: none;
+ }
+
+ &[data-variant="primary"] {
+ background-color: var(--icon-strong-base);
+
+ [data-slot="icon"] {
+ /* color: var(--icon-weak-base); */
+ color: var(--icon-invert-base);
+
+ /* &:hover:not(:disabled) { */
+ /* color: var(--icon-weak-hover); */
+ /* } */
+ /* &:active:not(:disabled) { */
+ /* color: var(--icon-string-active); */
+ /* } */
+ }
+
+ &:hover:not(:disabled) {
+ background-color: var(--icon-strong-hover);
+ }
+ &:active:not(:disabled) {
+ background-color: var(--icon-string-active);
+ }
+ &:focus:not(:disabled) {
+ background-color: var(--icon-strong-focus);
+ }
+ &:disabled {
+ background-color: var(--icon-strong-disabled);
+
+ [data-slot="icon"] {
+ color: var(--icon-invert-base);
+ }
+ }
+ }
+
+ &[data-variant="secondary"] {
+ background-color: var(--button-secondary-base);
+ color: var(--text-strong);
+
+ &:hover:not(:disabled) {
+ background-color: var(--surface-hover);
+ }
+ &:active:not(:disabled) {
+ background-color: var(--surface-active);
+ }
+ &:focus:not(:disabled) {
+ background-color: var(--surface-focus);
+ }
+ }
+
+ &[data-variant="ghost"] {
+ background-color: transparent;
+
+ [data-slot="icon"] {
+ color: var(--icon-weak-base);
+
+ &:hover:not(:disabled) {
+ color: var(--icon-weak-hover);
+ }
+ &:active:not(:disabled) {
+ color: var(--icon-string-active);
+ }
+ }
+
+ /* color: var(--text-strong); */
+ /**/
+ /* &:hover:not(:disabled) { */
+ /* background-color: var(--surface-hover); */
+ /* } */
+ /* &:active:not(:disabled) { */
+ /* background-color: var(--surface-active); */
+ /* } */
+ /* &:focus:not(:disabled) { */
+ /* background-color: var(--surface-focus); */
+ /* } */
+ }
+
+ &[data-size="normal"] {
+ width: 24px;
+ height: 24px;
+
+ font-size: var(--font-size-small);
+ line-height: var(--line-height-large);
+ gap: calc(var(--spacing) * 0.5);
+ }
+
+ &[data-size="large"] {
+ height: 32px;
+ padding: 0 8px 0 6px;
+ gap: 8px;
+
+ /* text-12-medium */
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-style: normal;
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large); /* 166.667% */
+ letter-spacing: var(--letter-spacing-normal);
+ }
+}
diff --git a/packages/ui/src/components/icon-button.tsx b/packages/ui/src/components/icon-button.tsx
new file mode 100644
index 00000000..f483f92a
--- /dev/null
+++ b/packages/ui/src/components/icon-button.tsx
@@ -0,0 +1,27 @@
+import { Button as Kobalte } from "@kobalte/core/button"
+import { type ComponentProps, splitProps } from "solid-js"
+import { Icon, IconProps } from "./icon"
+
+export interface IconButtonProps {
+ icon: IconProps["name"]
+ size?: "normal" | "large"
+ variant?: "primary" | "secondary" | "ghost"
+}
+
+export function IconButton(props: ComponentProps<"button"> & IconButtonProps) {
+ const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"])
+ return (
+
+
+
+ )
+}
diff --git a/packages/ui/src/components/icon.css b/packages/ui/src/components/icon.css
index abc19322..59c644b7 100644
--- a/packages/ui/src/components/icon.css
+++ b/packages/ui/src/components/icon.css
@@ -3,4 +3,27 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
+ /* resize: both; */
+ aspect-ratio: 1/1;
+ color: var(--icon-base);
+
+ &[data-size="small"] {
+ width: 16px;
+ height: 16px;
+ }
+
+ &[data-size="normal"] {
+ width: 20px;
+ height: 20px;
+ }
+
+ &[data-size="large"] {
+ width: 32px;
+ height: 32px;
+ }
+
+ [data-slot="svg"] {
+ width: 100%;
+ height: auto;
+ }
}
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
index 05dda6ea..8d63bf0f 100644
--- a/packages/ui/src/components/icon.tsx
+++ b/packages/ui/src/components/icon.tsx
@@ -128,28 +128,55 @@ const icons = {
mic: '',
} as const
+const newIcons = {
+ "circle-x": ``,
+ "magnifying-glass": ``,
+ "plus-small": ``,
+ "chevron-down": ``,
+ "arrow-up": ``,
+}
+
export interface IconProps extends ComponentProps<"svg"> {
- name: keyof typeof icons
- size?: number
+ name: keyof typeof icons | keyof typeof newIcons
+ size?: "small" | "normal" | "large"
}
export function Icon(props: IconProps) {
const [local, others] = splitProps(props, ["name", "size", "class", "classList"])
- const size = local.size ?? 24
+
+ if (local.name in newIcons) {
+ return (
+
+
+
+ )
+ }
+
return (
-
+
+
+
)
}
diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts
index d6ddc3ec..71cfd3a8 100644
--- a/packages/ui/src/components/index.ts
+++ b/packages/ui/src/components/index.ts
@@ -1,7 +1,11 @@
export * from "./button"
+export * from "./dialog"
export * from "./icon"
+export * from "./icon-button"
+export * from "./input"
export * from "./fonts"
export * from "./list"
export * from "./select"
+export * from "./select-dialog"
export * from "./tabs"
export * from "./tooltip"
diff --git a/packages/ui/src/components/input.css b/packages/ui/src/components/input.css
new file mode 100644
index 00000000..24cec19c
--- /dev/null
+++ b/packages/ui/src/components/input.css
@@ -0,0 +1,23 @@
+[data-component="input"] {
+ /* [data-slot="label"] {} */
+
+ [data-slot="input"] {
+ color: var(--text-strong);
+
+ /* 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);
+
+ &:focus {
+ outline: none;
+ }
+
+ &::placeholder {
+ color: var(--text-weak);
+ }
+ }
+}
diff --git a/packages/ui/src/components/input.tsx b/packages/ui/src/components/input.tsx
new file mode 100644
index 00000000..509e242c
--- /dev/null
+++ b/packages/ui/src/components/input.tsx
@@ -0,0 +1,27 @@
+import { TextField as Kobalte } from "@kobalte/core/text-field"
+import { Show, splitProps } from "solid-js"
+import type { ComponentProps } from "solid-js"
+
+export interface InputProps extends ComponentProps {
+ label?: string
+ hideLabel?: boolean
+ description?: string
+}
+
+export function Input(props: InputProps) {
+ const [local, others] = splitProps(props, ["class", "label", "hideLabel", "description", "placeholder"])
+ return (
+
+
+
+ {local.label}
+
+
+
+
+ {local.description}
+
+
+
+ )
+}
diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css
index b98cae07..d60b55ae 100644
--- a/packages/ui/src/components/list.css
+++ b/packages/ui/src/components/list.css
@@ -12,7 +12,6 @@
scrollbar-width: none;
[data-slot="item"] {
- cursor: pointer;
width: 100%;
padding: 4px 12px;
text-align: left;
@@ -23,6 +22,9 @@
&[data-active="true"] {
background-color: var(--surface-raised-base-hover);
}
+ &:hover {
+ background-color: var(--surface-raised-base-hover);
+ }
&:focus {
outline: none;
}
diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx
index 8bfbbdc9..cb212d1a 100644
--- a/packages/ui/src/components/list.tsx
+++ b/packages/ui/src/components/list.tsx
@@ -29,6 +29,7 @@ export function List(props: ListProps) {
// }
const handleSelect = (item: T) => {
props.onSelect?.(item)
+ list.setActive(props.key(item))
}
const handleKey = (e: KeyboardEvent) => {
@@ -64,10 +65,10 @@ export function List(props: ListProps) {
data-key={props.key(item)}
data-active={props.key(item) === list.active()}
onClick={() => handleSelect(item)}
- onMouseMove={(e) => {
- e.currentTarget.focus()
+ onMouseMove={() => {
+ // e.currentTarget.focus()
setStore("mouseActive", true)
- list.setActive(props.key(item))
+ // list.setActive(props.key(item))
}}
>
{props.children(item)}
diff --git a/packages/ui/src/components/select-dialog.css b/packages/ui/src/components/select-dialog.css
new file mode 100644
index 00000000..41d8f392
--- /dev/null
+++ b/packages/ui/src/components/select-dialog.css
@@ -0,0 +1,109 @@
+[data-component="select-dialog-input"] {
+ display: flex;
+ height: 40px;
+ flex-shrink: 0;
+ padding: 4px 10px 4px 6px;
+ align-items: center;
+ gap: 12px;
+ align-self: stretch;
+
+ border-radius: 8px;
+ background: var(--surface-base);
+
+ [data-slot="input-container"] {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex: 1 0 0;
+
+ /* [data-slot="icon"] {} */
+
+ [data-slot="input"] {
+ width: 100%;
+ }
+ }
+
+ /* [data-slot="clear-button"] {} */
+}
+
+[data-component="select-dialog"] {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ [data-slot="empty-state"] {
+ display: flex;
+ padding: 32px 160px;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 8px;
+ align-self: stretch;
+
+ [data-slot="message"] {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 2px;
+ color: var(--text-weak);
+ text-align: center;
+
+ /* 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="filter"] {
+ color: var(--text-strong);
+ }
+ }
+
+ [data-slot="group"] {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ [data-slot="header"] {
+ display: flex;
+ padding: 4px 8px;
+ justify-content: space-between;
+ align-items: center;
+ align-self: stretch;
+
+ color: var(--text-weak);
+
+ /* text-12-medium */
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-style: normal;
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large); /* 166.667% */
+ letter-spacing: var(--letter-spacing-normal);
+ }
+
+ [data-slot="list"] {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ align-self: stretch;
+
+ [data-slot="item"] {
+ display: flex;
+ width: 100%;
+ height: 32px;
+ padding: 4px 8px 4px 4px;
+ align-items: center;
+
+ &[data-active="true"] {
+ border-radius: 8px;
+ background: var(--surface-raised-base-hover);
+ }
+ }
+ }
+ }
+}
diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx
new file mode 100644
index 00000000..63fad13e
--- /dev/null
+++ b/packages/ui/src/components/select-dialog.tsx
@@ -0,0 +1,156 @@
+import { createEffect, Show, For, type JSX, splitProps } from "solid-js"
+import { Dialog, DialogProps, Icon, IconButton, Input } from "@opencode-ai/ui"
+import { createStore } from "solid-js/store"
+import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
+
+interface SelectDialogProps
+ extends FilteredListProps,
+ Pick {
+ title: string
+ placeholder?: string
+ emptyMessage?: string
+ children: (item: T) => JSX.Element
+ onSelect?: (value: T | undefined) => void
+}
+
+export function SelectDialog(props: SelectDialogProps) {
+ const [dialog, others] = splitProps(props, ["trigger", "onOpenChange", "defaultOpen"])
+ let closeButton!: HTMLButtonElement
+ let scrollRef: HTMLDivElement | undefined
+ const [store, setStore] = createStore({
+ mouseActive: false,
+ })
+
+ const { filter, grouped, flat, reset, clear, active, setActive, onKeyDown, onInput } = useFilteredList({
+ items: others.items,
+ key: others.key,
+ filterKeys: others.filterKeys,
+ current: others.current,
+ groupBy: others.groupBy,
+ sortBy: others.sortBy,
+ sortGroupsBy: others.sortGroupsBy,
+ })
+
+ createEffect(() => {
+ filter()
+ scrollRef?.scrollTo(0, 0)
+ reset()
+ })
+
+ createEffect(() => {
+ const all = flat()
+ if (store.mouseActive || all.length === 0) return
+ if (active() === others.key(all[0])) {
+ scrollRef?.scrollTo(0, 0)
+ return
+ }
+ const element = scrollRef?.querySelector(`[data-key="${active()}"]`)
+ element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
+ })
+
+ const handleInput = (value: string) => {
+ onInput(value)
+ reset()
+ }
+
+ const handleSelect = (item: T | undefined) => {
+ others.onSelect?.(item)
+ closeButton.click()
+ }
+
+ const handleKey = (e: KeyboardEvent) => {
+ setStore("mouseActive", false)
+ if (e.key === "Escape") return
+
+ if (e.key === "Enter") {
+ e.preventDefault()
+ const selected = flat().find((x) => others.key(x) === active())
+ if (selected) handleSelect(selected)
+ } else {
+ onKeyDown(e)
+ }
+ }
+
+ const handleOpenChange = (open: boolean) => {
+ if (!open) clear()
+ props.onOpenChange?.(open)
+ }
+
+ return (
+
+ )
+}
diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css
index b6b884a1..0eb7cea1 100644
--- a/packages/ui/src/components/select.css
+++ b/packages/ui/src/components/select.css
@@ -1,6 +1,7 @@
[data-component="select"] {
[data-slot="trigger"] {
padding: 0 4px 0 8px;
+ box-shadow: none;
[data-slot="value"] {
overflow: hidden;
@@ -8,8 +9,8 @@
white-space: nowrap;
}
[data-slot="icon"] {
- width: fit-content;
- height: fit-content;
+ width: 16px;
+ height: 16px;
flex-shrink: 0;
color: var(--text-weak);
transition: transform 0.1s ease-in-out;
@@ -18,15 +19,15 @@
}
[data-component="select-content"] {
- min-width: 8rem;
+ min-width: 4rem;
overflow: hidden;
- border-radius: var(--radius-md);
+ border-radius: 8px;
border-width: 1px;
border-style: solid;
border-color: var(--border-weak-base);
- background-color: var(--surface-raised-base);
- padding: calc(var(--spacing) * 1);
- box-shadow: var(--shadow-md);
+ background-color: var(--surface-raised-stronger-non-alpha);
+ padding: 2px;
+ box-shadow: var(--shadow-xs);
z-index: 50;
&[data-closed] {
@@ -42,36 +43,35 @@
max-height: 12rem;
white-space: nowrap;
overflow-x: hidden;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
&:focus {
outline: none;
}
}
- [data-slot="section"] {
- font-size: var(--text-xs);
- line-height: var(--text-xs--line-height);
- font-weight: var(--font-weight-light);
- text-transform: uppercase;
- color: var(--text-weak);
- opacity: 0.6;
- margin-top: calc(var(--spacing) * 3);
- margin-left: calc(var(--spacing) * 2);
- &:first-child {
- margin-top: 0;
- }
- }
+ /* [data-slot="section"] { */
+ /* } */
[data-slot="item"] {
position: relative;
display: flex;
align-items: center;
- padding: calc(var(--spacing) * 2) calc(var(--spacing) * 2);
- border-radius: var(--radius-sm);
- font-size: var(--text-xs);
- line-height: var(--text-xs--line-height);
- color: var(--text-base);
- cursor: pointer;
+ padding: 0 6px 0 6px;
+ border-radius: 6px;
+
+ /* text-12-medium */
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-style: normal;
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large); /* 166.667% */
+ letter-spacing: var(--letter-spacing-normal);
+
+ color: var(--text-strong);
+
transition:
background-color 0.2s ease-in-out,
color 0.2s ease-in-out;
@@ -79,24 +79,20 @@
user-select: none;
&[data-highlighted] {
- background-color: var(--surface-base);
+ background: var(--surface-raised-base-hover);
}
-
&[data-disabled] {
- background-color: var(--surface-disabled);
+ background-color: var(--surface-raised-base);
pointer-events: none;
}
-
[data-slot="item-indicator"] {
margin-left: auto;
}
-
&:focus {
outline: none;
}
-
&:hover {
- background-color: var(--surface-hover);
+ background: var(--surface-raised-base-hover);
}
}
}
diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx
index ecf05d5e..111608e2 100644
--- a/packages/ui/src/components/select.tsx
+++ b/packages/ui/src/components/select.tsx
@@ -52,7 +52,7 @@ export function Select(props: SelectProps & ButtonProps) {
{props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)}
-
+
)}
@@ -79,7 +79,7 @@ export function Select(props: SelectProps & ButtonProps) {
}}
-
+
diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css
index c6d09c65..70d7b03e 100644
--- a/packages/ui/src/components/tabs.css
+++ b/packages/ui/src/components/tabs.css
@@ -10,7 +10,7 @@
background-color: var(--background-stronger);
overflow: clip;
- & [data-slot="list"] {
+ [data-slot="list"] {
width: 100%;
position: relative;
display: flex;
@@ -40,7 +40,7 @@
}
}
- & [data-slot="trigger"] {
+ [data-slot="trigger"] {
position: relative;
height: 36px;
padding: 8px 12px;
@@ -49,7 +49,7 @@
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
color: var(--text-weak);
- cursor: pointer;
+
white-space: nowrap;
flex-shrink: 0;
border-bottom: 1px solid var(--border-weak-base);
@@ -77,7 +77,7 @@
}
}
- & [data-slot="content"] {
+ [data-slot="content"] {
overflow-y: auto;
flex: 1;
diff --git a/packages/ui/src/components/tooltip.tsx b/packages/ui/src/components/tooltip.tsx
index 14e433e2..ff13c8d6 100644
--- a/packages/ui/src/components/tooltip.tsx
+++ b/packages/ui/src/components/tooltip.tsx
@@ -36,7 +36,7 @@ export function Tooltip(props: TooltipProps) {
{typeof others.value === "function" ? others.value() : others.value}
- {/* */}
+ {/* */}
diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts
new file mode 100644
index 00000000..7eef7809
--- /dev/null
+++ b/packages/ui/src/hooks/index.ts
@@ -0,0 +1 @@
+export * from "./use-filtered-list"
diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx
new file mode 100644
index 00000000..b3ddf69e
--- /dev/null
+++ b/packages/ui/src/hooks/use-filtered-list.tsx
@@ -0,0 +1,89 @@
+import fuzzysort from "fuzzysort"
+import { entries, flatMap, groupBy, map, pipe } from "remeda"
+import { createMemo, createResource } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createList } from "solid-list"
+
+export interface FilteredListProps {
+ items: T[] | ((filter: string) => Promise)
+ key: (item: T) => string
+ filterKeys?: string[]
+ current?: T
+ groupBy?: (x: T) => string
+ sortBy?: (a: T, b: T) => number
+ sortGroupsBy?: (a: { category: string; items: T[] }, b: { category: string; items: T[] }) => number
+ onSelect?: (value: T | undefined) => void
+}
+
+export function useFilteredList(props: FilteredListProps) {
+ const [store, setStore] = createStore<{ filter: string }>({ filter: "" })
+
+ const [grouped] = createResource(
+ () => store.filter,
+ async (filter) => {
+ const needle = filter?.toLowerCase()
+ const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || []
+ const result = pipe(
+ all,
+ (x) => {
+ if (!needle) return x
+ if (!props.filterKeys && Array.isArray(x) && x.every((e) => typeof e === "string")) {
+ return fuzzysort.go(needle, x).map((x) => x.target) as T[]
+ }
+ return fuzzysort.go(needle, x, { keys: props.filterKeys! }).map((x) => x.obj)
+ },
+ groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
+ entries(),
+ map(([k, v]) => ({ category: k, items: props.sortBy ? v.sort(props.sortBy) : v })),
+ (groups) => (props.sortGroupsBy ? groups.sort(props.sortGroupsBy) : groups),
+ )
+ 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) : props.key(flat()[0]),
+ loop: true,
+ })
+
+ const reset = () => {
+ const all = flat()
+ if (all.length === 0) return
+ list.setActive(props.key(all[0]))
+ }
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Enter") {
+ event.preventDefault()
+ const selected = flat().find((x) => props.key(x) === list.active())
+ if (selected) props.onSelect?.(selected)
+ } else {
+ list.onKeyDown(event)
+ }
+ }
+
+ const onInput = (value: string) => {
+ setStore("filter", value)
+ reset()
+ }
+
+ return {
+ filter: () => store.filter,
+ grouped,
+ flat,
+ reset,
+ clear: () => setStore("filter", ""),
+ onKeyDown,
+ onInput,
+ active: list.active,
+ setActive: list.setActive,
+ }
+}
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index 0a89a4a0..dc5335c4 100644
--- a/packages/ui/src/styles/index.css
+++ b/packages/ui/src/styles/index.css
@@ -6,9 +6,13 @@
@import "./base.css" layer(base);
@import "../components/button.css" layer(components);
+@import "../components/dialog.css" layer(components);
@import "../components/icon.css" layer(components);
+@import "../components/icon-button.css" layer(components);
+@import "../components/input.css" layer(components);
@import "../components/list.css" layer(components);
@import "../components/select.css" layer(components);
+@import "../components/select-dialog.css" layer(components);
@import "../components/tabs.css" layer(components);
@import "../components/tooltip.css" layer(components);
diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css
index f01a6b2e..7d14b653 100644
--- a/packages/ui/src/styles/utilities.css
+++ b/packages/ui/src/styles/utilities.css
@@ -5,11 +5,11 @@
pointer-events: none;
}
- ::selection {
- background-color: color-mix(in srgb, var(--color-primary) 33%, transparent);
- /* background-color: var(--color-primary); */
- /* color: var(--color-background); */
- }
+ /* ::selection { */
+ /* background-color: color-mix(in srgb, var(--color-primary) 33%, transparent); */
+ /* background-color: var(--color-primary); */
+ /* color: var(--color-background); */
+ /* } */
::-webkit-scrollbar-track {
background: var(--theme-background-panel);
@@ -36,6 +36,18 @@
}
}
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border-width: 0;
+}
+
.text-12-regular {
font-family: var(--font-family-sans);
font-size: var(--font-size-small);