diff options
| author | Adam <[email protected]> | 2025-10-22 17:31:44 -0500 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-10-22 17:31:49 -0500 |
| commit | 89b703c387aed3ee918d826b788b4be1729bdde9 (patch) | |
| tree | 9a22d26bfc5e921789924e2344d04113660b67a3 /packages/desktop/src | |
| parent | eff12cb48453e45590a53a7705c5044a0da9e7f7 (diff) | |
| download | opencode-89b703c387aed3ee918d826b788b4be1729bdde9.tar.gz opencode-89b703c387aed3ee918d826b788b4be1729bdde9.zip | |
wip: desktop work
Diffstat (limited to 'packages/desktop/src')
| -rw-r--r-- | packages/desktop/src/components/code.tsx | 2 | ||||
| -rw-r--r-- | packages/desktop/src/components/editor-pane.tsx | 37 | ||||
| -rw-r--r-- | packages/desktop/src/components/file-tree.tsx | 4 | ||||
| -rw-r--r-- | packages/desktop/src/components/prompt-form-helpers.ts | 6 | ||||
| -rw-r--r-- | packages/desktop/src/components/prompt-form-hooks.ts | 2 | ||||
| -rw-r--r-- | packages/desktop/src/components/prompt-input.tsx | 428 | ||||
| -rw-r--r-- | packages/desktop/src/components/select-dialog.tsx | 226 | ||||
| -rw-r--r-- | packages/desktop/src/components/session-timeline.tsx | 47 | ||||
| -rw-r--r-- | packages/desktop/src/pages/index.tsx | 168 | ||||
| -rw-r--r-- | packages/desktop/src/ui/collapsible.tsx | 2 | ||||
| -rw-r--r-- | packages/desktop/src/ui/icon-button.tsx | 38 | ||||
| -rw-r--r-- | packages/desktop/src/ui/index.ts | 1 |
12 files changed, 406 insertions, 555 deletions
diff --git a/packages/desktop/src/components/code.tsx b/packages/desktop/src/components/code.tsx index 40a40aa9a..b4dd216e9 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 2741a6208..a97a0ef7f 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 { <Show when={view !== "raw"}> <div class="mr-1 flex items-center gap-1"> <Tooltip value="Previous change" placement="bottom"> - <IconButton size="xs" variant="ghost" onClick={() => navigateChange(-1)}> - <Icon name="arrow-up" size={14} /> - </IconButton> + <IconButton icon="arrow-up" variant="ghost" onClick={() => navigateChange(-1)} /> </Tooltip> <Tooltip value="Next change" placement="bottom"> - <IconButton size="xs" variant="ghost" onClick={() => navigateChange(1)}> - <Icon name="arrow-down" size={14} /> - </IconButton> + <IconButton icon="arrow-down" variant="ghost" onClick={() => navigateChange(1)} /> </Tooltip> </div> </Show> <Tooltip value="Raw" placement="bottom"> <IconButton - size="xs" + icon="file-text" variant="ghost" classList={{ "text-text": view === "raw", @@ -113,13 +108,11 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element { "bg-background-element": view === "raw", }} onClick={() => local.file.setView(activeFile.path, "raw")} - > - <Icon name="file-text" size={14} /> - </IconButton> + /> </Tooltip> <Tooltip value="Unified diff" placement="bottom"> <IconButton - size="xs" + icon="checklist" variant="ghost" classList={{ "text-text": view === "diff-unified", @@ -127,13 +120,11 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element { "bg-background-element": view === "diff-unified", }} onClick={() => local.file.setView(activeFile.path, "diff-unified")} - > - <Icon name="checklist" size={14} /> - </IconButton> + /> </Tooltip> <Tooltip value="Split diff" placement="bottom"> <IconButton - size="xs" + icon="columns" variant="ghost" classList={{ "text-text": view === "diff-split", @@ -141,9 +132,7 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element { "bg-background-element": view === "diff-split", }} onClick={() => local.file.setView(activeFile.path, "diff-split")} - > - <Icon name="columns" size={14} /> - </IconButton> + /> </Tooltip> </div> ) @@ -221,13 +210,11 @@ function SortableTab(props: { <TabVisual file={props.file} /> </Tabs.Trigger> <IconButton + icon="close" class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100" - size="xs" variant="ghost" onClick={() => props.onTabClose(props.file)} - > - <Icon name="close" size={16} /> - </IconButton> + /> </div> </Tooltip> </div> diff --git a/packages/desktop/src/components/file-tree.tsx b/packages/desktop/src/components/file-tree.tsx index 348e25ad7..7e4b1abcc 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: { <Dynamic component={p.as ?? "div"} classList={{ - "p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element cursor-pointer": true, + "p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element": true, "bg-background-element": local.file.active()?.path === p.node.path, [props.nodeClass ?? ""]: !!props.nodeClass, }} @@ -83,7 +83,7 @@ export default function FileTree(props: { > <Collapsible.Trigger> <Node node={node}> - <Collapsible.Arrow size={16} class="text-text-muted/60 ml-1" /> + <Collapsible.Arrow class="text-text-muted/60 ml-1" /> <FileIcon node={node} expanded={local.file.node(node.path).expanded} diff --git a/packages/desktop/src/components/prompt-form-helpers.ts b/packages/desktop/src/components/prompt-form-helpers.ts index cc657c80e..298b831ee 100644 --- a/packages/desktop/src/components/prompt-form-helpers.ts +++ b/packages/desktop/src/components/prompt-form-helpers.ts @@ -7,7 +7,7 @@ export interface PromptTextPart { } export interface PromptAttachmentPart { - kind: "attachment" + kind: "file" token: string display: string path: string @@ -106,7 +106,7 @@ export function parsePrompt(value: string, lookup: Map<string, AttachmentCandida const start = rangeStart + localIndex const end = start + match[0].length segments.push({ - kind: "attachment", + kind: "file", token, display: candidate.display, path: candidate.path, @@ -152,7 +152,7 @@ export function composeDisplaySegments( } const { start, end, ...part } = segment const placeholder = inputValue.slice(start, end) - return { kind: "attachment", part: part as PromptAttachmentPart, source: placeholder } + return { kind: "file", part: part as PromptAttachmentPart, source: placeholder } }) if (interim) { diff --git a/packages/desktop/src/components/prompt-form-hooks.ts b/packages/desktop/src/components/prompt-form-hooks.ts index 026b84359..c4d18fa70 100644 --- a/packages/desktop/src/components/prompt-form-hooks.ts +++ b/packages/desktop/src/components/prompt-form-hooks.ts @@ -309,7 +309,7 @@ export function useMentionController(options: MentionControllerOptions) { const nextValue = `${leading}${mentionText}${trailingSpacer}${after}` const origin = options.getActiveContext()?.path === path ? "active" : "context" const part: PromptAttachmentPart = { - kind: "attachment", + kind: "file", token: alias, display: createAttachmentDisplay(path, node?.selection), path, diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 0f8fb5930..55a410516 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,63 +1,74 @@ -import { createEffect, on, Component, createMemo, Show } from "solid-js" +import { useLocal } from "@/context" +import { Button, Icon, IconButton, Select, SelectDialog, Tooltip } from "@opencode-ai/ui" +import { useFilteredList } from "@opencode-ai/ui/hooks" +import { createEffect, on, Component, createMemo, Show, Switch, Match, For } from "solid-js" import { createStore } from "solid-js/store" +import { FileIcon } from "@/ui" +import { getDirectory, getFilename } from "@/utils" +import { createFocusSignal } from "@solid-primitives/active-element" +import { TextSelection } from "@/context/local" +import { DateTime } from "luxon" -interface TextPart { - type: "text" +interface PartBase { content: string } -interface AttachmentPart { - type: "attachment" - fileId: string - name: string +interface TextPart extends PartBase { + type: "text" } -export type ContentPart = TextPart | AttachmentPart - -export interface AttachmentToAdd { - id: string - name: string +interface FileAttachmentPart extends PartBase { + type: "file" + path: string + selection?: TextSelection } -type AddAttachmentCallback = (attachment: AttachmentToAdd) => 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<PromptInputProps> = (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<string>({ + 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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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 + + 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 + } - // Create new structured content - const newParts: ContentPart[] = [] - const textBeforeTrigger = rawText.substring(0, startIndex) - if (textBeforeTrigger) newParts.push({ type: "text", content: textBeforeTrigger }) + const nextIndex = acc.runningIndex + item.content.length + if (nextIndex <= startIndex) { + acc.parts.push(item) + acc.runningIndex = nextIndex + return acc + } + + if (item.type !== "text") { + acc.parts.push(item) + acc.runningIndex = nextIndex + return acc + } + + 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) - newParts.push({ type: "attachment", fileId: attachment.id, name: attachment.name }) + if (head) acc.parts.push({ type: "text", content: head }) - // Add a space after the pill for better UX - newParts.push({ type: "text", content: " " }) + 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, + }, + ) - const textAfterCursor = rawText.substring(cursorPosition) - if (textAfterCursor) newParts.push({ type: "text", content: textAfterCursor }) + if (!inserted || cursorIndex === null) return - setStore("contentParts", newParts) - setStore("popover", "isOpen", false) + 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 ( - <div - classList={{ - "size-full max-w-xl bg-surface-raised-stronger-non-alpha border border-border-strong-base": true, - "rounded-2xl overflow-clip focus-within:shadow-xs-border-selected": true, - [props.class ?? ""]: !!props.class, - }} - > - <div class="p-3" /> - <div class="relative"> - <div - ref={editorRef} - contenteditable="true" - onInput={handleInput} - onKeyDown={handleKeyDown} - classList={{ - "w-full p-3 text-sm focus:outline-none": true, - }} - /> - <Show when={isEmpty()}> - <div class="absolute bottom-0 left-0 p-3 text-sm text-text-weak pointer-events-none"> - Plan and build anything + <div class="relative size-full max-w-[640px] _max-h-[320px] flex flex-col gap-3"> + <Show when={store.popoverIsOpen}> + <div class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10 overflow-y-auto flex flex-col p-2 pb-0 rounded-2xl border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"> + <For each={flat()}> + {(i) => ( + <div + classList={{ + "w-full flex items-center justify-between rounded-md": true, + "bg-surface-raised-base-hover": active() === i, + }} + > + <div class="flex items-center gap-x-2 grow min-w-0"> + <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" /> + <div class="flex items-center text-14-regular"> + <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0"> + {getDirectory(i)}/ + </span> + <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span> + </div> + </div> + <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div> + </div> + )} + </For> + </div> + </Show> + <form + onSubmit={handleSubmit} + classList={{ + "bg-surface-raised-stronger-non-alpha border border-border-strong-base": true, + "rounded-2xl overflow-clip focus-within:shadow-xs-border-selected": true, + [props.class ?? ""]: !!props.class, + }} + > + <div class="relative max-h-[240px] overflow-y-auto"> + <div + ref={(el) => { + 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, + }} + /> + <Show when={isEmpty()}> + <div class="absolute top-0 left-0 p-3 text-14-regular text-text-weak pointer-events-none"> + Plan and build anything + </div> + </Show> + </div> + <div class="p-3 flex items-center justify-between"> + <div class="flex items-center justify-start gap-1"> + <Select + options={local.agent.list().map((agent) => agent.name)} + current={local.agent.current().name} + onSelect={local.agent.set} + class="capitalize" + /> + <SelectDialog + title="Select model" + placeholder="Search models" + emptyMessage="No model results" + key={(x) => `${x.provider.id}:${x.id}`} + items={local.model.list()} + current={local.model.current()} + filterKeys={["provider.name", "name", "id"]} + groupBy={(x) => x.provider.name} + sortGroupsBy={(a, b) => { + const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"] + const aProvider = a.items[0].provider.id + const bProvider = b.items[0].provider.id + if (order.includes(aProvider) && !order.includes(bProvider)) return -1 + if (!order.includes(aProvider) && order.includes(bProvider)) return 1 + return order.indexOf(aProvider) - order.indexOf(bProvider) + }} + onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)} + trigger={ + <Button as="div" variant="ghost"> + {local.model.current()?.name ?? "Select model"} + <span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span> + <Icon name="chevron-down" size="small" /> + </Button> + } + > + {(i) => ( + <div class="w-full flex items-center justify-between gap-x-3"> + <div class="flex items-center gap-x-2.5 text-text-muted grow min-w-0"> + <img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0 " /> + <div class="flex gap-x-3 items-baseline flex-[1_0_0]"> + <span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span> + <span class="text-12-medium text-text-weak overflow-hidden text-ellipsis truncate min-w-0"> + {DateTime.fromFormat(i.release_date, "yyyy-MM-dd").toFormat("LLL yyyy")} + </span> + </div> + </div> + <Show when={!i.cost || i.cost?.input === 0}> + <div class="overflow-hidden text-12-medium text-text-strong">Free</div> + </Show> + </div> + )} + </SelectDialog> </div> - </Show> - </div> - <div class="p-3" /> + <IconButton type="submit" disabled={isEmpty()} icon="arrow-up" variant="primary" /> + </div> + </form> </div> ) } @@ -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 bf9aa0dbd..000000000 --- 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<T> { - items: T[] | ((filter: string) => Promise<T[]>) - 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<T>(props: SelectDialogProps<T>) { - 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 ( - <Dialog defaultOpen modal onOpenChange={(open) => open || props.onClose?.()}> - <Dialog.Portal> - <Dialog.Overlay class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100]" /> - <Dialog.Content - class="fixed top-[20%] left-1/2 -translate-x-1/2 w-[90vw] max-w-2xl - shadow-[0_0_33px_rgba(0,0,0,0.8)] - bg-background border border-border-subtle/30 rounded-lg z-[101] - max-h-[60vh] flex flex-col" - > - <div class="border-b border-border-subtle/30"> - <div class="relative"> - <Icon name="command" size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted/80" /> - <input - type="text" - value={store.filter} - onInput={(e) => handleInput(e.currentTarget.value)} - onKeyDown={handleKey} - placeholder={props.placeholder} - class="w-full pl-10 pr-4 py-2 rounded-t-md - text-sm text-text placeholder-text-muted/70 - focus:outline-none" - autofocus - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - /> - <div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2"> - {/* <Show when={fileResults.loading && mode() === "files"}> - <div class="text-text-muted"> - <Icon name="refresh" size={14} class="animate-spin" /> - </div> - </Show> */} - <Show when={store.filter}> - <IconButton - size="xs" - variant="ghost" - class="text-text-muted hover:text-text" - onClick={() => { - setStore("filter", "") - resetSelection() - }} - > - <Icon name="close" size={14} /> - </IconButton> - </Show> - </div> - </div> - </div> - <div ref={(el) => (scrollRef = el)} class="relative flex-1 overflow-y-auto"> - <Show - when={flat().length > 0} - fallback={<div class="text-center py-8 text-text-muted text-sm">No results</div>} - > - <For each={grouped()}> - {(group) => ( - <> - <Show when={group.category}> - <div class="top-0 sticky z-10 bg-background-panel p-2 text-xs text-text-muted/60 tracking-wider uppercase"> - {group.category} - </div> - </Show> - <div class="p-2"> - <For each={group.items}> - {(item) => ( - <button - data-key={props.key(item)} - onClick={() => handleSelect(item)} - onMouseMove={() => { - setStore("mouseActive", true) - list.setActive(props.key(item)) - }} - classList={{ - "w-full px-3 py-2 flex items-center gap-3": true, - "rounded-md text-left transition-colors group": true, - "bg-background-element": props.key(item) === list.active(), - }} - > - {props.render(item)} - </button> - )} - </For> - </div> - </> - )} - </For> - </Show> - </div> - <div class="p-3 border-t border-border-subtle/30 flex items-center justify-between text-xs text-text-muted"> - <div class="flex items-center gap-5"> - <span class="flex items-center gap-1.5"> - <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]"> - ↑↓ - </kbd> - Navigate - </span> - <span class="flex items-center gap-1.5"> - <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]"> - ↵ - </kbd> - Select - </span> - <span class="flex items-center gap-1.5"> - <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]"> - ESC - </kbd> - Close - </span> - </div> - <span>{`${flat().length} results`}</span> - </div> - </Dialog.Content> - </Dialog.Portal> - </Dialog> - ) -} diff --git a/packages/desktop/src/components/session-timeline.tsx b/packages/desktop/src/components/session-timeline.tsx index 2474b3101..0d8a7cd3c 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 ( - <Part class="cursor-pointer" onClick={() => local.file.open(path)}> + <Part onClick={() => local.file.open(path)}> <span class="">Read</span> {getFilename(path)} </Part> ) @@ -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, }} > - <div class="py-1.5 px-10 flex justify-end items-center self-stretch"> + <div class="py-1.5 px-6 flex justify-end items-center self-stretch"> <div class="flex items-center gap-6"> <Tooltip value={`${tokens()} Tokens`} class="flex items-center gap-1.5"> <Show when={context()}> @@ -397,11 +405,16 @@ export default function SessionTimeline(props: { session: string; class?: string <div class="text-14-regular text-text-strong text-right">{cost()}</div> </div> </div> - <ul role="list" class="flex flex-col gap-6 items-start self-stretch px-10 pt-2 pb-6"> - <For each={messages()}> + <ul role="list" class="flex flex-col items-start self-stretch px-6 pt-2 pb-6 gap-1"> + <For each={messagesWithValidParts()}> {(message) => ( - <div class="flex flex-col gap-1 justify-center items-start self-stretch"> - <For each={sync.data.part[message.id]?.filter(valid)}> + <div + classList={{ + "flex flex-col gap-1 justify-center items-start self-stretch": true, + "mt-6": hasTextPart(message), + }} + > + <For each={sync.data.part[message.id]?.filter(valid) ?? []}> {(part) => ( <li class="group/li"> <Switch fallback={<div class="">{part.type}</div>}> @@ -449,9 +462,9 @@ export default function SessionTimeline(props: { session: string; class?: string <Collapsible defaultOpen={false}> <Collapsible.Trigger> <div class="mt-12 ml-1 flex items-center gap-x-2 text-xs text-text-muted"> - <Icon name="file-code" size={16} /> + <Icon name="file-code" /> <span>Raw Session Data</span> - <Collapsible.Arrow size={18} class="text-text-muted" /> + <Collapsible.Arrow class="text-text-muted" /> </div> </Collapsible.Trigger> <Collapsible.Content class="mt-5"> @@ -460,9 +473,9 @@ export default function SessionTimeline(props: { session: string; class?: string <Collapsible> <Collapsible.Trigger> <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1"> - <Icon name="file-code" size={16} /> + <Icon name="file-code" /> <span>session</span> - <Collapsible.Arrow size={18} class="text-text-muted" /> + <Collapsible.Arrow class="text-text-muted" /> </div> </Collapsible.Trigger> <Collapsible.Content> @@ -477,9 +490,9 @@ export default function SessionTimeline(props: { session: string; class?: string <Collapsible> <Collapsible.Trigger> <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1"> - <Icon name="file-code" size={16} /> + <Icon name="file-code" /> <span>{message.role === "user" ? "user" : "assistant"}</span> - <Collapsible.Arrow size={18} class="text-text-muted" /> + <Collapsible.Arrow class="text-text-muted" /> </div> </Collapsible.Trigger> <Collapsible.Content> @@ -493,9 +506,9 @@ export default function SessionTimeline(props: { session: string; class?: string <Collapsible> <Collapsible.Trigger> <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1"> - <Icon name="file-code" size={16} /> + <Icon name="file-code" /> <span>{part.type}</span> - <Collapsible.Arrow size={18} class="text-text-muted" /> + <Collapsible.Arrow class="text-text-muted" /> </div> </Collapsible.Trigger> <Collapsible.Content> diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 80473d84a..58d479111 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<string, SubmissionAttachment>() 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<PromptContentPart, { kind: "attachment" }> => 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 = ( - <IconButton - class="text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100" - size="xs" - variant="secondary" - onClick={() => setStore("fileSelectOpen", true)} - > - <Icon name="plus" size={12} /> - </IconButton> - ) + const handleNewSession = () => { + local.session.setActive(undefined) + inputRef?.focus() + } return ( <div class="relative h-screen flex flex-col"> @@ -234,7 +221,8 @@ export default function Page() { </div> <div class="flex flex-col items-start gap-4 self-stretch flex-1"> <div class="px-3 py-1.5 w-full"> - <Button class="w-full" size="large"> + <Button class="w-full" size="large" onClick={handleNewSession}> + <Icon name="plus" /> New Session </Button> </div> @@ -268,25 +256,30 @@ export default function Page() { </List> </div> </div> - <div class="relative grid grid-cols-2 bg-background-base"> + <div class="relative grid grid-cols-2 bg-background-base w-full"> <div class="pt-1.5 min-w-0 overflow-y-auto no-scrollbar flex justify-center"> <Show when={local.session.active()}> {(activeSession) => <SessionTimeline session={activeSession().id} class="w-full" />} </Show> </div> <div class="p-1.5 pl-px flex flex-col items-center justify-center overflow-y-auto no-scrollbar"> - <EditorPane onFileClick={handleFileClick} /> + <Show when={local.session.active()}> + <EditorPane onFileClick={handleFileClick} /> + </Show> </div> - <div class="absolute bottom-4 inset-x-0 p-2 flex flex-col justify-center items-center z-50"> - <PromptInput onSubmit={handlePromptSubmit2} /> - {/* <PromptForm */} - {/* class="w-2xl" */} - {/* onSubmit={handlePromptSubmit} */} - {/* onOpenModelSelect={() => setStore("modelSelectOpen", true)} */} - {/* onInputRefChange={(element: HTMLTextAreaElement | undefined) => { */} - {/* inputRef = element ?? undefined */} - {/* }} */} - {/* /> */} + <div + classList={{ + "absolute inset-x-0 px-8 flex flex-col justify-center items-center z-50": true, + "bottom-8": !!local.session.active(), + "bottom-1/2 translate-y-1/2": !local.session.active(), + }} + > + <PromptInput + ref={(el) => { + inputRef = el + }} + onSubmit={handlePromptSubmit} + /> </div> <div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto"> <FileTree path="" onFileClick={handleFileClick} /> @@ -302,7 +295,7 @@ export default function Page() { <li> <button onClick={() => 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" > <FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" /> <span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span> @@ -318,59 +311,16 @@ export default function Page() { </div> </div> </main> - <Show when={store.modelSelectOpen}> - <SelectDialog - key={(x) => `${x.provider.id}:${x.id}`} - items={local.model.list()} - current={local.model.current()} - render={(i) => ( - <div class="w-full flex items-center justify-between"> - <div class="flex items-center gap-x-2 text-text-muted grow min-w-0"> - <img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-4 invert opacity-40" /> - <span class="text-xs text-text whitespace-nowrap">{i.name}</span> - <span class="text-xs text-text-muted/80 whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0"> - {i.id} - </span> - </div> - <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"> - <Tooltip forceMount={false} value="Reasoning"> - <Icon name="brain" size={16} classList={{ "text-accent": i.reasoning }} /> - </Tooltip> - <Tooltip forceMount={false} value="Tools"> - <Icon name="hammer" size={16} classList={{ "text-secondary": i.tool_call }} /> - </Tooltip> - <Tooltip forceMount={false} value="Attachments"> - <Icon name="photo" size={16} classList={{ "text-success": i.attachment }} /> - </Tooltip> - <div class="rounded-full bg-text-muted/20 text-text-muted/80 w-9 h-4 flex items-center justify-center text-[10px]"> - {new Intl.NumberFormat("en-US", { - notation: "compact", - compactDisplay: "short", - }).format(i.limit.context)} - </div> - <Tooltip forceMount={false} value={`$${i.cost?.input}/1M input, $${i.cost?.output}/1M output`}> - <div class="rounded-full bg-success/20 text-success/80 w-9 h-4 flex items-center justify-center text-[10px]"> - <Switch fallback="FREE"> - <Match when={i.cost?.input > 10}>$$$</Match> - <Match when={i.cost?.input > 1}>$$</Match> - <Match when={i.cost?.input > 0.1}>$</Match> - </Switch> - </div> - </Tooltip> - </div> - </div> - )} - filter={["provider.name", "name", "id"]} - groupBy={(x) => x.provider.name} - onClose={() => setStore("modelSelectOpen", false)} - onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)} - /> - </Show> <Show when={store.fileSelectOpen}> <SelectDialog + defaultOpen + title="Select file" items={local.file.search} key={(x) => x} - render={(i) => ( + onOpenChange={(open) => setStore("fileSelectOpen", open)} + onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)} + > + {(i) => ( <div class="w-full flex items-center justify-between"> <div class="flex items-center gap-x-2 text-text-muted grow min-w-0"> <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" /> @@ -382,9 +332,7 @@ export default function Page() { <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div> </div> )} - onClose={() => setStore("fileSelectOpen", false)} - onSelect={(x) => (x ? local.file.open(x, { pinned: true }) : undefined)} - /> + </SelectDialog> </Show> </div> ) diff --git a/packages/desktop/src/ui/collapsible.tsx b/packages/desktop/src/ui/collapsible.tsx index d17b3e623..fbc6fcbfe 100644 --- a/packages/desktop/src/ui/collapsible.tsx +++ b/packages/desktop/src/ui/collapsible.tsx @@ -16,7 +16,7 @@ function CollapsibleTrigger(props: CollapsibleTriggerProps) { return ( <KobalteCollapsible.Trigger classList={{ - "w-full group/collapsible cursor-pointer": true, + "w-full group/collapsible": true, [local.class ?? ""]: !!local.class, }} {...others} diff --git a/packages/desktop/src/ui/icon-button.tsx b/packages/desktop/src/ui/icon-button.tsx deleted file mode 100644 index 7e9e3e6e2..000000000 --- a/packages/desktop/src/ui/icon-button.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Button as KobalteButton } from "@kobalte/core/button" -import { splitProps } from "solid-js" -import type { ComponentProps, JSX } from "solid-js" - -export interface IconButtonProps extends ComponentProps<typeof KobalteButton> { - 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 ( - <KobalteButton - classList={{ - ...(local.classList || {}), - "inline-flex items-center justify-center rounded-md font-medium cursor-pointer": true, - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2": true, - "disabled:pointer-events-none disabled:opacity-50": true, - "bg-primary text-background hover:bg-secondary focus-visible:ring-primary data-[disabled]:opacity-50": - (local.variant || "primary") === "primary", - "bg-background-panel text-text hover:bg-background-element focus-visible:ring-secondary data-[disabled]:opacity-50": - local.variant === "secondary", - "border border-border bg-transparent text-text hover:bg-background-panel": local.variant === "outline", - "focus-visible:ring-border-active data-[disabled]:border-border-subtle data-[disabled]:text-text-muted": - local.variant === "outline", - "text-text hover:bg-background-panel focus-visible:ring-border-active data-[disabled]:text-text-muted": - local.variant === "ghost", - "h-5 w-5 text-xs": local.size === "xs", - "h-8 w-8 text-sm": local.size === "sm", - "h-10 w-10 text-sm": (local.size || "md") === "md", - "h-12 w-12 text-base": local.size === "lg", - [local.class ?? ""]: !!local.class, - }} - {...others} - /> - ) -} diff --git a/packages/desktop/src/ui/index.ts b/packages/desktop/src/ui/index.ts index a6ade6ff3..e273e8efe 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" |
