diff --git a/bun.lock b/bun.lock index 1c9f5f76..057e4692 100644 --- a/bun.lock +++ b/bun.lock @@ -115,6 +115,7 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", "@shikijs/transformers": "3.9.2", + "@solid-primitives/active-element": "2.1.3", "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/scroll": "2.1.3", @@ -273,7 +274,9 @@ "version": "0.15.13", "dependencies": { "@kobalte/core": "catalog:", + "@pierre/precision-diffs": "0.0.2-alpha.1-1", "@solidjs/meta": "catalog:", + "fuzzysort": "catalog:", "luxon": "catalog:", "remeda": "catalog:", "solid-js": "catalog:", @@ -931,6 +934,8 @@ "@petamoriken/float16": ["@petamoriken/float16@3.9.2", "", {}, "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog=="], + "@pierre/precision-diffs": ["@pierre/precision-diffs@0.0.2-alpha.1-1", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/transformers": "3.13.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.13.0" } }, "sha512-T43cwB7gMnbM+tp9p73NptUm4uUOfmrP5ihMOAHWQPpzBa/oeTjqZlmEmSQLpT8WKKnWG0lbKZPtlw7l0gW0Vw=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@planetscale/database": ["@planetscale/database@1.19.0", "", {}, "sha512-Tv4jcFUFAFjOWrGSio49H6R2ijALv0ZzVBfJKIdm+kl9X046Fh4LLawrF9OMsglVbK6ukqMJsUCeucGAFTBcMA=="], @@ -1181,6 +1186,8 @@ "@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="], + "@solid-primitives/active-element": ["@solid-primitives/active-element@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9t5K4aR2naVDj950XU8OjnLgOg94a8k5wr6JNOPK+N5ESLsJDq42c1ZP8UKpewi1R+wplMMxiM6OPKRzbxJY7A=="], + "@solid-primitives/event-bus": ["@solid-primitives/event-bus@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-l+n10/51neGcMaP3ypYt21bXfoeWh8IaC8k7fYuY3ww2a8S1Zv2N2a7FF5Qn+waTu86l0V8/nRHjkyqVIZBYwA=="], "@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg=="], @@ -3503,6 +3510,12 @@ "@parcel/watcher-wasm/napi-wasm": ["napi-wasm@1.1.3", "", { "bundled": true }, "sha512-h/4nMGsHjZDCYmQVNODIrYACVJ+I9KItbG+0si6W/jSjdA9JbWDoU4LLeMXVcEQGHjttI2tuXqDrbGF7qkUHHg=="], + "@pierre/precision-diffs/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="], + + "@pierre/precision-diffs/@shikijs/transformers": ["@shikijs/transformers@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/types": "3.13.0" } }, "sha512-833lcuVzcRiG+fXvgslWsM2f4gHpjEgui1ipIknSizRuTgMkNZupiXE5/TVJ6eSYfhNBFhBZKkReKWO2GgYmqA=="], + + "@pierre/precision-diffs/shiki": ["shiki@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/engine-javascript": "3.13.0", "@shikijs/engine-oniguruma": "3.13.0", "@shikijs/langs": "3.13.0", "@shikijs/themes": "3.13.0", "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g=="], + "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], "@rollup/plugin-commonjs/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], @@ -4065,6 +4078,20 @@ "@opencode-ai/web/shiki/@shikijs/types": ["@shikijs/types@3.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="], + "@pierre/precision-diffs/@shikijs/core/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="], + + "@pierre/precision-diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="], + + "@pierre/precision-diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="], + + "@pierre/precision-diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="], + + "@pierre/precision-diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ=="], + + "@pierre/precision-diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg=="], + + "@pierre/precision-diffs/shiki/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="], + "@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], "@slack/web-api/p-queue/p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="], diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 6e3cc3ac..aacd2da3 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -25,7 +25,9 @@ "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/ui": "workspace:*", "@shikijs/transformers": "3.9.2", + "@solid-primitives/active-element": "2.1.3", "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/scroll": "2.1.3", @@ -33,14 +35,13 @@ "@solidjs/router": "0.15.3", "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", - "@opencode-ai/ui": "workspace:*", "fuzzysort": "catalog:", "luxon": "catalog:", "marked": "16.2.0", "marked-shiki": "1.2.1", "remeda": "catalog:", - "solid-js": "catalog:", "shiki": "3.9.2", + "solid-js": "catalog:", "solid-list": "catalog:", "tailwindcss": "catalog:", "virtua": "catalog:" diff --git a/packages/desktop/src/components/code.tsx b/packages/desktop/src/components/code.tsx index 40a40aa9..b4dd216e 100644 --- a/packages/desktop/src/components/code.tsx +++ b/packages/desktop/src/components/code.tsx @@ -394,7 +394,7 @@ export function Code(props: Props) { [&_.diff-blank_.diff-oldln]:bg-background-element [&_.diff-blank_.diff-newln]:bg-background-element [&_.diff-collapsed]:block! [&_.diff-collapsed]:w-full [&_.diff-collapsed]:relative - [&_.diff-collapsed]:cursor-pointer [&_.diff-collapsed]:select-none + [&_.diff-collapsed]:select-none [&_.diff-collapsed]:bg-info/20 [&_.diff-collapsed]:hover:bg-info/40! [&_.diff-collapsed]:text-info/80 [&_.diff-collapsed]:hover:text-info [&_.diff-collapsed]:text-xs diff --git a/packages/desktop/src/components/editor-pane.tsx b/packages/desktop/src/components/editor-pane.tsx index 2741a620..a97a0ef7 100644 --- a/packages/desktop/src/components/editor-pane.tsx +++ b/packages/desktop/src/components/editor-pane.tsx @@ -1,7 +1,6 @@ import { For, Match, Show, Switch, createSignal, splitProps } from "solid-js" -import { Tabs, Tooltip } from "@opencode-ai/ui" -import { Icon } from "@opencode-ai/ui" -import { FileIcon, IconButton } from "@/ui" +import { IconButton, Tabs, Tooltip } from "@opencode-ai/ui" +import { FileIcon } from "@/ui" import { DragDropProvider, DragDropSensors, @@ -92,20 +91,16 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
- navigateChange(-1)}> - - + navigateChange(-1)} /> - navigateChange(1)}> - - + navigateChange(1)} />
local.file.setView(activeFile.path, "raw")} - > - - + /> local.file.setView(activeFile.path, "diff-unified")} - > - - + /> local.file.setView(activeFile.path, "diff-split")} - > - - + /> ) @@ -221,13 +210,11 @@ function SortableTab(props: { props.onTabClose(props.file)} - > - - + /> diff --git a/packages/desktop/src/components/file-tree.tsx b/packages/desktop/src/components/file-tree.tsx index 348e25ad..7e4b1abc 100644 --- a/packages/desktop/src/components/file-tree.tsx +++ b/packages/desktop/src/components/file-tree.tsx @@ -19,7 +19,7 @@ export default function FileTree(props: { - + void - -export interface PopoverState { - isOpen: boolean - searchQuery: string - addAttachment: AddAttachmentCallback -} +export type ContentPart = TextPart | FileAttachmentPart interface PromptInputProps { onSubmit: (parts: ContentPart[]) => void - onShowAttachments?: (state: PopoverState | null) => void class?: string + ref?: (el: HTMLDivElement) => void } export const PromptInput: Component = (props) => { - let editorRef: HTMLDivElement | undefined + const local = useLocal() + let editorRef!: HTMLDivElement const defaultParts = [{ type: "text", content: "" } as const] const [store, setStore] = createStore<{ contentParts: ContentPart[] - popover: { - isOpen: boolean - searchQuery: string - } + popoverIsOpen: boolean }>({ contentParts: defaultParts, - popover: { - isOpen: false, - searchQuery: "", - }, + popoverIsOpen: false, }) const isEmpty = createMemo(() => isEqual(store.contentParts, defaultParts)) + const isFocused = createFocusSignal(() => editorRef) + + createEffect(() => { + if (isFocused()) { + handleInput() + } else { + setStore("popoverIsOpen", false) + } + }) + + const { flat, active, onInput, onKeyDown } = useFilteredList({ + items: local.file.search, + key: (x) => x, + onSelect: (path) => { + if (!path) return + addPart({ type: "file", path, content: "@" + getFilename(path) }) + setStore("popoverIsOpen", false) + }, + }) createEffect( on( () => store.contentParts, (currentParts) => { - if (!editorRef) return const domParts = parseFromDOM() if (isEqual(currentParts, domParts)) return @@ -70,14 +81,16 @@ export const PromptInput: Component = (props) => { editorRef.innerHTML = "" currentParts.forEach((part) => { if (part.type === "text") { - editorRef!.appendChild(document.createTextNode(part.content)) - } else if (part.type === "attachment") { + editorRef.appendChild(document.createTextNode(part.content)) + } else if (part.type === "file") { const pill = document.createElement("span") - pill.textContent = `@${part.name}` - pill.className = "attachment-pill" - pill.setAttribute("data-file-id", part.fileId) + pill.textContent = part.content + pill.setAttribute("data-type", "file") + pill.setAttribute("data-path", part.path) pill.setAttribute("contenteditable", "false") - editorRef!.appendChild(pill) + pill.style.userSelect = "text" + pill.style.cursor = "default" + editorRef.appendChild(pill) } }) @@ -88,30 +101,23 @@ export const PromptInput: Component = (props) => { ), ) - createEffect(() => { - if (store.popover.isOpen) { - props.onShowAttachments?.({ - isOpen: true, - searchQuery: store.popover.searchQuery, - addAttachment: addAttachment, - }) - } else { - props.onShowAttachments?.(null) - } - }) - const parseFromDOM = (): ContentPart[] => { - if (!editorRef) return [] const newParts: ContentPart[] = [] editorRef.childNodes.forEach((node) => { if (node.nodeType === Node.TEXT_NODE) { if (node.textContent) newParts.push({ type: "text", content: node.textContent }) - } else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.fileId) { - newParts.push({ - type: "attachment", - fileId: (node as HTMLElement).dataset.fileId!, - name: node.textContent!.substring(1), - }) + } else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type) { + switch ((node as HTMLElement).dataset.type) { + case "file": + newParts.push({ + type: "file", + path: (node as HTMLElement).dataset.path!, + content: node.textContent!, + }) + break + default: + break + } } }) if (newParts.length === 0) newParts.push(...defaultParts) @@ -120,96 +126,234 @@ export const PromptInput: Component = (props) => { const handleInput = () => { const rawParts = parseFromDOM() - const cursorPosition = getCursorPosition(editorRef!) - const rawText = rawParts.map((p) => (p.type === "text" ? p.content : `@${p.name}`)).join("") + const cursorPosition = getCursorPosition(editorRef) + const rawText = rawParts.map((p) => p.content).join("") const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) if (atMatch) { - setStore("popover", { isOpen: true, searchQuery: atMatch[1] }) - } else if (store.popover.isOpen) { - setStore("popover", "isOpen", false) + onInput(atMatch[1]) + setStore("popoverIsOpen", true) + } else if (store.popoverIsOpen) { + setStore("popoverIsOpen", false) } setStore("contentParts", rawParts) } - const addAttachment: AddAttachmentCallback = (attachment) => { - const rawText = store.contentParts.map((p) => (p.type === "text" ? p.content : `@${p.name}`)).join("") - const cursorPosition = getCursorPosition(editorRef!) - + const addPart = (part: ContentPart) => { + const cursorPosition = getCursorPosition(editorRef) + const rawText = store.contentParts.map((p) => p.content).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) - if (!atMatch) return const startIndex = atMatch.index! + const endIndex = cursorPosition - // Create new structured content - const newParts: ContentPart[] = [] - const textBeforeTrigger = rawText.substring(0, startIndex) - if (textBeforeTrigger) newParts.push({ type: "text", content: textBeforeTrigger }) + const { + parts: nextParts, + cursorIndex, + cursorOffset, + inserted, + } = store.contentParts.reduce( + (acc, item) => { + if (acc.inserted) { + acc.parts.push(item) + acc.runningIndex += item.content.length + return acc + } - newParts.push({ type: "attachment", fileId: attachment.id, name: attachment.name }) + const nextIndex = acc.runningIndex + item.content.length + if (nextIndex <= startIndex) { + acc.parts.push(item) + acc.runningIndex = nextIndex + return acc + } - // Add a space after the pill for better UX - newParts.push({ type: "text", content: " " }) + if (item.type !== "text") { + acc.parts.push(item) + acc.runningIndex = nextIndex + return acc + } - const textAfterCursor = rawText.substring(cursorPosition) - if (textAfterCursor) newParts.push({ type: "text", content: textAfterCursor }) + const headLength = Math.max(0, startIndex - acc.runningIndex) + const tailLength = Math.max(0, endIndex - acc.runningIndex) + const head = item.content.slice(0, headLength) + const tail = item.content.slice(tailLength) - setStore("contentParts", newParts) - setStore("popover", "isOpen", false) + if (head) acc.parts.push({ type: "text", content: head }) + + acc.parts.push(part) + + const rest = /^\s/.test(tail) ? tail : ` ${tail}` + if (rest) { + acc.cursorIndex = acc.parts.length + acc.cursorOffset = Math.min(1, rest.length) + acc.parts.push({ type: "text", content: rest }) + } + + acc.inserted = true + acc.runningIndex = nextIndex + return acc + }, + { + parts: [] as ContentPart[], + runningIndex: 0, + inserted: false, + cursorIndex: null as number | null, + cursorOffset: 0, + }, + ) + + if (!inserted || cursorIndex === null) return + + setStore("contentParts", nextParts) + setStore("popoverIsOpen", false) - // Set cursor position after the newly added pill + space - // We need to wait for the DOM to update queueMicrotask(() => { - setCursorPosition(editorRef!, textBeforeTrigger.length + 1 + attachment.name.length + 1) + const node = editorRef.childNodes[cursorIndex] + if (node && node.nodeType === Node.TEXT_NODE) { + const range = document.createRange() + const selection = window.getSelection() + const length = node.textContent ? node.textContent.length : 0 + const offset = cursorOffset > length ? length : cursorOffset + range.setStart(node, offset) + range.collapse(true) + selection?.removeAllRanges() + selection?.addRange(range) + } }) } const handleKeyDown = (event: KeyboardEvent) => { - if (store.popover.isOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { - // In a real implementation, you'd prevent default and delegate this to the popover - console.log("Key press delegated to popover:", event.key) + if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { + onKeyDown(event) event.preventDefault() return } - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault() - if (store.contentParts.length > 0) { - props.onSubmit([...store.contentParts]) - setStore("contentParts", defaultParts) - } + handleSubmit(event) + } + } + + const handleSubmit = (event: Event) => { + event.preventDefault() + if (store.contentParts.length > 0) { + props.onSubmit([...store.contentParts]) + setStore("contentParts", defaultParts) } } return ( -
-
-
-
- -
- Plan and build anything +
+ +
+ + {(i) => ( +
+
+ +
+ + {getDirectory(i)}/ + + {getFilename(i)} +
+
+
+
+ )} +
+
+
+
+
+
{ + editorRef = el + props.ref?.(el) + }} + contenteditable="true" + onInput={handleInput} + onKeyDown={handleKeyDown} + classList={{ + "w-full p-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, + "[&>[data-type=file]]:text-icon-info-active": true, + }} + /> + +
+ Plan and build anything +
+
+
+
+
+ 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" - /> -
- {/* -
- -
-
*/} - - { - setStore("filter", "") - resetSelection() - }} - > - - - -
-
-
-
(scrollRef = el)} class="relative flex-1 overflow-y-auto"> - 0} - fallback={
No results
} - > - - {(group) => ( - <> - -
- {group.category} -
-
-
- - {(item) => ( - - )} - -
- - )} -
-
-
-
-
- - - ↑↓ - - Navigate - - - - ↵ - - Select - - - - ESC - - Close - -
- {`${flat().length} results`} -
- - - - ) -} 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} + />
      - - `${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 ( + + + {others.title} + + +
      +
      + + handleInput(value)} + onKeyDown={handleKey} + placeholder={others.placeholder} + autofocus + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + /> +
      + + { + onInput("") + reset() + }} + /> + +
      + + 0} + fallback={ +
      +
      + {props.emptyMessage ?? "No search results"} for "{filter()}" +
      +
      + } + > + + {(group) => ( +
      + +
      {group.category}
      +
      +
      + + {(item) => ( + + )} + +
      +
      + )} +
      +
      +
      +
      + ) +} 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);