diff options
| author | Adam <[email protected]> | 2025-10-16 14:01:49 -0500 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-10-16 14:07:37 -0500 |
| commit | 20229f147bbe5e096e1863391246eaaa798f40ce (patch) | |
| tree | 2946240638555779eb5e3e3148fc29bae6ab37e4 /packages/desktop/src/components | |
| parent | 149cb6a9ec2a03db70332218f970be4f25ee5ba9 (diff) | |
| download | opencode-20229f147bbe5e096e1863391246eaaa798f40ce.tar.gz opencode-20229f147bbe5e096e1863391246eaaa798f40ce.zip | |
wip: css/ui and desktop work
Diffstat (limited to 'packages/desktop/src/components')
| -rw-r--r-- | packages/desktop/src/components/prompt-input.tsx | 264 | ||||
| -rw-r--r-- | packages/desktop/src/components/session-list.tsx | 2 |
2 files changed, 265 insertions, 1 deletions
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx new file mode 100644 index 000000000..48d238401 --- /dev/null +++ b/packages/desktop/src/components/prompt-input.tsx @@ -0,0 +1,264 @@ +import { createEffect, on, Component, createMemo, Show } from "solid-js" +import { createStore } from "solid-js/store" + +interface TextPart { + type: "text" + content: string +} + +interface AttachmentPart { + type: "attachment" + fileId: string + name: string +} + +export type ContentPart = TextPart | AttachmentPart + +export interface AttachmentToAdd { + id: string + name: string +} + +type AddAttachmentCallback = (attachment: AttachmentToAdd) => void + +export interface PopoverState { + isOpen: boolean + searchQuery: string + addAttachment: AddAttachmentCallback +} + +interface PromptInputProps { + onSubmit: (parts: ContentPart[]) => void + onShowAttachments?: (state: PopoverState | null) => void + class?: string +} + +export const PromptInput: Component<PromptInputProps> = (props) => { + let editorRef: HTMLDivElement | undefined + + const defaultParts = [{ type: "text", content: "" } as const] + const [store, setStore] = createStore<{ + contentParts: ContentPart[] + popover: { + isOpen: boolean + searchQuery: string + } + }>({ + contentParts: defaultParts, + popover: { + isOpen: false, + searchQuery: "", + }, + }) + + const isEmpty = createMemo(() => isEqual(store.contentParts, defaultParts)) + + createEffect( + on( + () => store.contentParts, + (currentParts) => { + if (!editorRef) return + const domParts = parseFromDOM() + if (isEqual(currentParts, domParts)) return + + const selection = window.getSelection() + let cursorPosition: number | null = null + if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) { + cursorPosition = getCursorPosition(editorRef) + } + + editorRef.innerHTML = "" + currentParts.forEach((part) => { + if (part.type === "text") { + editorRef!.appendChild(document.createTextNode(part.content)) + } else if (part.type === "attachment") { + const pill = document.createElement("span") + pill.textContent = `@${part.name}` + pill.className = "attachment-pill" + pill.setAttribute("data-file-id", part.fileId) + pill.setAttribute("contenteditable", "false") + editorRef!.appendChild(pill) + } + }) + + if (cursorPosition !== null) { + setCursorPosition(editorRef, cursorPosition) + } + }, + ), + ) + + 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), + }) + } + }) + if (newParts.length === 0) newParts.push(...defaultParts) + return newParts + } + + const handleInput = () => { + const rawParts = parseFromDOM() + const cursorPosition = getCursorPosition(editorRef!) + const rawText = rawParts.map((p) => (p.type === "text" ? p.content : `@${p.name}`)).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) + } + + 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 textBeforeCursor = rawText.substring(0, cursorPosition) + const atMatch = textBeforeCursor.match(/@(\S*)$/) + + if (!atMatch) return + + const startIndex = atMatch.index! + + // Create new structured content + const newParts: ContentPart[] = [] + const textBeforeTrigger = rawText.substring(0, startIndex) + if (textBeforeTrigger) newParts.push({ type: "text", content: textBeforeTrigger }) + + newParts.push({ type: "attachment", fileId: attachment.id, name: attachment.name }) + + // Add a space after the pill for better UX + newParts.push({ type: "text", content: " " }) + + const textAfterCursor = rawText.substring(cursorPosition) + if (textAfterCursor) newParts.push({ type: "text", content: textAfterCursor }) + + setStore("contentParts", newParts) + setStore("popover", "isOpen", 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 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) + event.preventDefault() + return + } + + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault() + if (store.contentParts.length > 0) { + props.onSubmit([...store.contentParts]) + setStore("contentParts", defaultParts) + } + } + } + + return ( + <div + classList={{ + "size-full max-w-xl bg-surface-base border border-border-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> + </Show> + </div> + <div class="p-3" /> + </div> + ) +} + +function isEqual(arrA: ContentPart[], arrB: ContentPart[]): boolean { + if (arrA.length !== arrB.length) return false + for (let i = 0; i < arrA.length; i++) { + const partA = arrA[i] + const partB = arrB[i] + if (partA.type !== partB.type) return false + if (partA.type === "text" && partA.content !== (partB as TextPart).content) { + return false + } + if (partA.type === "attachment" && partA.fileId !== (partB as AttachmentPart).fileId) { + return false + } + } + return true +} + +function getCursorPosition(parent: HTMLElement): number { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return 0 + const range = selection.getRangeAt(0) + const preCaretRange = range.cloneRange() + preCaretRange.selectNodeContents(parent) + preCaretRange.setEnd(range.startContainer, range.startOffset) + return preCaretRange.toString().length +} + +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) + } + return + } + } +} diff --git a/packages/desktop/src/components/session-list.tsx b/packages/desktop/src/components/session-list.tsx index 5b6dc0f5a..a339fc6b4 100644 --- a/packages/desktop/src/components/session-list.tsx +++ b/packages/desktop/src/components/session-list.tsx @@ -7,7 +7,7 @@ export default function SessionList() { const sync = useSync() const local = useLocal() return ( - <VList data={sync.data.session} class="p-3"> + <VList data={sync.data.session} class="no-scrollbar p-3"> {(session) => ( <Tooltip placement="right" value={session.title} class="w-full min-w-0"> <button |
