From 89b703c387aed3ee918d826b788b4be1729bdde9 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:31:44 -0500 Subject: wip: desktop work --- packages/desktop/src/components/code.tsx | 2 +- packages/desktop/src/components/editor-pane.tsx | 37 +- packages/desktop/src/components/file-tree.tsx | 4 +- .../desktop/src/components/prompt-form-helpers.ts | 6 +- .../desktop/src/components/prompt-form-hooks.ts | 2 +- packages/desktop/src/components/prompt-input.tsx | 428 ++++++++++++++------- packages/desktop/src/components/select-dialog.tsx | 226 ----------- .../desktop/src/components/session-timeline.tsx | 47 ++- packages/desktop/src/pages/index.tsx | 168 +++----- packages/desktop/src/ui/collapsible.tsx | 2 +- packages/desktop/src/ui/icon-button.tsx | 38 -- packages/desktop/src/ui/index.ts | 1 - 12 files changed, 406 insertions(+), 555 deletions(-) delete mode 100644 packages/desktop/src/components/select-dialog.tsx delete mode 100644 packages/desktop/src/ui/icon-button.tsx (limited to 'packages/desktop/src') 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 {
- 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 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: { - + 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 + + 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 ( -
-
-
-
- -
- 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 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 ( - 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 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() 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 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 ( { - 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 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" -- cgit v1.2.3