From e9b95b2e9199bfd94d6ad2f67a12ef5ae60f4f21 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 04:09:57 -0600 Subject: wip(desktop): progress --- packages/desktop/src/components/prompt-input.tsx | 203 +++++++++++++++-------- 1 file changed, 138 insertions(+), 65 deletions(-) (limited to 'packages/desktop/src/components') diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 0c1be77db..6ab280fa6 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,5 +1,5 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" -import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js" +import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js" import { createStore } from "solid-js/store" import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" @@ -19,6 +19,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { useProviders } from "@/hooks/use-providers" +import { useCommand, formatKeybind } from "@/context/command" interface PromptInputProps { class?: string @@ -53,6 +54,14 @@ const PLACEHOLDERS = [ "How do environment variables work here?", ] +interface SlashCommand { + id: string + trigger: string + title: string + description?: string + keybind?: string +} + export const PromptInput: Component = (props) => { const navigate = useNavigate() const sdk = useSDK() @@ -61,18 +70,21 @@ export const PromptInput: Component = (props) => { const session = useSession() const dialog = useDialog() const providers = useProviders() + const command = useCommand() let editorRef!: HTMLDivElement const [store, setStore] = createStore<{ - popoverIsOpen: boolean + popover: "file" | "slash" | null historyIndex: number savedPrompt: Prompt | null placeholder: number + slashFilter: string }>({ - popoverIsOpen: false, + popover: null, historyIndex: -1, savedPrompt: null, placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), + slashFilter: "", }) const MAX_HISTORY = 100 @@ -157,17 +169,17 @@ export const PromptInput: Component = (props) => { } onMount(() => { - editorRef.addEventListener("paste", handlePaste) + editorRef?.addEventListener("paste", handlePaste) }) onCleanup(() => { - editorRef.removeEventListener("paste", handlePaste) + editorRef?.removeEventListener("paste", handlePaste) }) createEffect(() => { if (isFocused()) { handleInput() } else { - setStore("popoverIsOpen", false) + setStore("popover", null) } }) @@ -182,6 +194,53 @@ export const PromptInput: Component = (props) => { onSelect: handleFileSelect, }) + // Get slash commands from registered commands (only those with explicit slash trigger) + const slashCommands = createMemo(() => + command.options + .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) + .map((opt) => ({ + id: opt.id, + trigger: opt.slash!, + title: opt.title, + description: opt.description, + keybind: opt.keybind, + })), + ) + + const handleSlashSelect = (cmd: SlashCommand | undefined) => { + if (!cmd) return + // Since slash commands only trigger from start, just clear the input + editorRef.innerHTML = "" + session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + setStore("popover", null) + command.trigger(cmd.id, "slash") + } + + const { + flat: slashFlat, + active: slashActive, + onInput: slashOnInput, + onKeyDown: slashOnKeyDown, + } = useFilteredList({ + items: () => { + const filter = store.slashFilter.toLowerCase() + return slashCommands().filter( + (cmd) => + cmd.trigger.toLowerCase().includes(filter) || + cmd.title.toLowerCase().includes(filter) || + cmd.description?.toLowerCase().includes(filter) || + false, + ) + }, + key: (x) => x?.id, + onSelect: handleSlashSelect, + }) + + // Update slash filter when store changes + createEffect(() => { + slashOnInput(store.slashFilter) + }) + createEffect( on( () => session.prompt.current(), @@ -256,11 +315,17 @@ export const PromptInput: Component = (props) => { const rawText = rawParts.map((p) => p.content).join("") const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) + // Slash commands only trigger when / is at the start of input + const slashMatch = rawText.match(/^\/(\S*)$/) + if (atMatch) { onInput(atMatch[1]) - setStore("popoverIsOpen", true) - } else if (store.popoverIsOpen) { - setStore("popoverIsOpen", false) + setStore("popover", "file") + } else if (slashMatch) { + setStore("slashFilter", slashMatch[1]) + setStore("popover", "slash") + } else { + setStore("popover", null) } if (store.historyIndex >= 0) { @@ -294,8 +359,6 @@ export const PromptInput: Component = (props) => { const range = selection.getRangeAt(0) if (atMatch) { - // let node: Node | null = range.startContainer - // let offset = range.startOffset let runningLength = 0 const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null) @@ -335,7 +398,7 @@ export const PromptInput: Component = (props) => { } handleInput() - setStore("popoverIsOpen", false) + setStore("popover", null) } const abort = () => @@ -403,8 +466,13 @@ export const PromptInput: Component = (props) => { } const handleKeyDown = (event: KeyboardEvent) => { - if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { - onKeyDown(event) + // Handle popover navigation + if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { + if (store.popover === "file") { + onKeyDown(event) + } else { + slashOnKeyDown(event) + } event.preventDefault() return } @@ -441,8 +509,8 @@ export const PromptInput: Component = (props) => { handleSubmit(event) } if (event.key === "Escape") { - if (store.popoverIsOpen) { - setStore("popoverIsOpen", false) + if (store.popover) { + setStore("popover", null) } else if (session.working()) { abort() } @@ -470,31 +538,9 @@ export const PromptInput: Component = (props) => { } if (!existing) return - // if (!session.id) { - // session.layout.setOpenedTabs( - // session.layout.copyTabs("", session.id) - // } - const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) const attachments = prompt.filter((part) => part.type === "file") - // 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), - // ) - // } - const attachmentParts = attachments.map((attachment) => { const absolute = toAbsolutePath(attachment.path) const query = attachment.selection @@ -519,7 +565,6 @@ export const PromptInput: Component = (props) => { session.layout.setActiveTab(undefined) session.messages.setActive(undefined) - // Clear the editor DOM directly to ensure it's empty editorRef.innerHTML = "" session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) @@ -542,38 +587,66 @@ export const PromptInput: Component = (props) => { return (
- + {/* Popover for file mentions and slash commands */} +
- 0} fallback={
No matching files
}> - - {(i) => ( - + )} + +
+ + + 0} + fallback={
No matching commands
} + > + + {(cmd) => ( +
-
-
- - )} - - + + )} + + + +
Date: Mon, 15 Dec 2025 04:37:14 -0600 Subject: wip(desktop): progress --- packages/desktop/src/app.tsx | 11 +- .../desktop/src/components/dialog-select-file.tsx | 11 +- packages/desktop/src/components/prompt-input.tsx | 78 +++-- packages/desktop/src/components/terminal.tsx | 2 +- packages/desktop/src/context/command.tsx | 2 +- packages/desktop/src/context/layout.tsx | 92 +++++- packages/desktop/src/context/prompt.tsx | 100 +++++++ packages/desktop/src/context/session.tsx | 321 --------------------- packages/desktop/src/context/terminal.tsx | 106 +++++++ packages/desktop/src/pages/session.tsx | 184 ++++++++---- 10 files changed, 483 insertions(+), 424 deletions(-) create mode 100644 packages/desktop/src/context/prompt.tsx delete mode 100644 packages/desktop/src/context/session.tsx create mode 100644 packages/desktop/src/context/terminal.tsx (limited to 'packages/desktop/src/components') diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx index 6414d0d49..2530f92dd 100644 --- a/packages/desktop/src/app.tsx +++ b/packages/desktop/src/app.tsx @@ -9,7 +9,8 @@ import { Diff } from "@opencode-ai/ui/diff" import { GlobalSyncProvider } from "@/context/global-sync" import { LayoutProvider } from "@/context/layout" import { GlobalSDKProvider } from "@/context/global-sdk" -import { SessionProvider } from "@/context/session" +import { TerminalProvider } from "@/context/terminal" +import { PromptProvider } from "@/context/prompt" import { NotificationProvider } from "@/context/notification" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { CommandProvider } from "@/context/command" @@ -53,9 +54,11 @@ export function App() { path="/session/:id?" component={(p) => ( - - - + + + + + )} /> diff --git a/packages/desktop/src/components/dialog-select-file.tsx b/packages/desktop/src/components/dialog-select-file.tsx index 0250963b0..b719e15d2 100644 --- a/packages/desktop/src/components/dialog-select-file.tsx +++ b/packages/desktop/src/components/dialog-select-file.tsx @@ -3,13 +3,18 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { FileIcon } from "@opencode-ai/ui/file-icon" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { useSession } from "@/context/session" +import { useLayout } from "@/context/layout" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useParams } from "@solidjs/router" +import { createMemo } from "solid-js" export function DialogSelectFile() { - const session = useSession() + const layout = useLayout() const local = useLocal() const dialog = useDialog() + const params = useParams() + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey())) return ( x} onSelect={(path) => { if (path) { - session.layout.openTab("file://" + path) + tabs().open("file://" + path) } dialog.clear() }} diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 6ab280fa6..a498593bd 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -4,9 +4,10 @@ import { createStore } from "solid-js/store" import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" -import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session" +import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt } from "@/context/prompt" +import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" -import { useNavigate } from "@solidjs/router" +import { useNavigate, useParams } from "@solidjs/router" import { useSync } from "@/context/sync" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Button } from "@opencode-ai/ui/button" @@ -67,12 +68,26 @@ export const PromptInput: Component = (props) => { const sdk = useSDK() const sync = useSync() const local = useLocal() - const session = useSession() + const prompt = usePrompt() + const layout = useLayout() + const params = useParams() const dialog = useDialog() const providers = useProviders() const command = useCommand() let editorRef!: HTMLDivElement + // Session-derived state + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey())) + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const status = createMemo( + () => + sync.data.session_status[params.id ?? ""] ?? { + type: "idle", + }, + ) + const working = createMemo(() => status()?.type !== "idle") + const [store, setStore] = createStore<{ popover: "file" | "slash" | null historyIndex: number @@ -111,9 +126,9 @@ export const PromptInput: Component = (props) => { const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0) - const applyHistoryPrompt = (prompt: Prompt, position: "start" | "end") => { - const length = position === "start" ? 0 : promptLength(prompt) - session.prompt.set(prompt, length) + const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { + const length = position === "start" ? 0 : promptLength(p) + prompt.set(p, length) requestAnimationFrame(() => { editorRef.focus() setCursorPosition(editorRef, length) @@ -149,9 +164,9 @@ export const PromptInput: Component = (props) => { } createEffect(() => { - session.id + params.id editorRef.focus() - if (session.id) return + if (params.id) return const interval = setInterval(() => { setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length) }, 6500) @@ -211,7 +226,7 @@ export const PromptInput: Component = (props) => { if (!cmd) return // Since slash commands only trigger from start, just clear the input editorRef.innerHTML = "" - session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) setStore("popover", null) command.trigger(cmd.id, "slash") } @@ -243,7 +258,7 @@ export const PromptInput: Component = (props) => { createEffect( on( - () => session.prompt.current(), + () => prompt.current(), (currentParts) => { const domParts = parseFromDOM() if (isPromptEqual(currentParts, domParts)) return @@ -255,7 +270,7 @@ export const PromptInput: Component = (props) => { } editorRef.innerHTML = "" - currentParts.forEach((part) => { + currentParts.forEach((part: ContentPart) => { if (part.type === "text") { editorRef.appendChild(document.createTextNode(part.content)) } else if (part.type === "file") { @@ -333,7 +348,7 @@ export const PromptInput: Component = (props) => { setStore("savedPrompt", null) } - session.prompt.set(rawParts, cursorPosition) + prompt.set(rawParts, cursorPosition) } const addPart = (part: ContentPart) => { @@ -341,8 +356,8 @@ export const PromptInput: Component = (props) => { if (!selection || selection.rangeCount === 0) return const cursorPosition = getCursorPosition(editorRef) - const prompt = session.prompt.current() - const rawText = prompt.map((p) => p.content).join("") + const currentPrompt = prompt.current() + const rawText = currentPrompt.map((p: ContentPart) => p.content).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) @@ -403,7 +418,7 @@ export const PromptInput: Component = (props) => { const abort = () => sdk.client.session.abort({ - sessionID: session.id!, + sessionID: params.id!, }) const addToHistory = (prompt: Prompt) => { @@ -430,7 +445,7 @@ export const PromptInput: Component = (props) => { if (direction === "up") { if (entries.length === 0) return false if (current === -1) { - setStore("savedPrompt", clonePromptParts(session.prompt.current())) + setStore("savedPrompt", clonePromptParts(prompt.current())) setStore("historyIndex", 0) applyHistoryPrompt(entries[0], "start") return true @@ -481,7 +496,7 @@ export const PromptInput: Component = (props) => { const { collapsed, onFirstLine, onLastLine } = getCaretLineState() if (!collapsed) return const cursorPos = getCursorPosition(editorRef) - const textLength = promptLength(session.prompt.current()) + const textLength = promptLength(prompt.current()) const inHistory = store.historyIndex >= 0 const isStart = cursorPos === 0 const isEnd = cursorPos === textLength @@ -511,7 +526,7 @@ export const PromptInput: Component = (props) => { if (event.key === "Escape") { if (store.popover) { setStore("popover", null) - } else if (session.working()) { + } else if (working()) { abort() } } @@ -519,18 +534,18 @@ export const PromptInput: Component = (props) => { const handleSubmit = async (event: Event) => { event.preventDefault() - const prompt = session.prompt.current() - const text = prompt.map((part) => part.content).join("") + const currentPrompt = prompt.current() + const text = currentPrompt.map((part: ContentPart) => part.content).join("") if (text.trim().length === 0) { - if (session.working()) abort() + if (working()) abort() return } - addToHistory(prompt) + addToHistory(currentPrompt) setStore("historyIndex", -1) setStore("savedPrompt", null) - let existing = session.info() + let existing = info() if (!existing) { const created = await sdk.client.session.create() existing = created.data ?? undefined @@ -539,7 +554,9 @@ export const PromptInput: Component = (props) => { if (!existing) return const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) - const attachments = prompt.filter((part) => part.type === "file") + const attachments = currentPrompt.filter( + (part: ContentPart) => part.type === "file", + ) as import("@/context/prompt").FileAttachmentPart[] const attachmentParts = attachments.map((attachment) => { const absolute = toAbsolutePath(attachment.path) @@ -563,10 +580,9 @@ export const PromptInput: Component = (props) => { } }) - session.layout.setActiveTab(undefined) - session.messages.setActive(undefined) + tabs().setActive(undefined) editorRef.innerHTML = "" - session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) sdk.client.session.prompt({ sessionID: existing.id, @@ -671,7 +687,7 @@ export const PromptInput: Component = (props) => { "[&>[data-type=file]]:text-icon-info-active": true, }} /> - +
Ask anything... "{PLACEHOLDERS[store.placeholder]}"
@@ -703,7 +719,7 @@ export const PromptInput: Component = (props) => { inactive={!session.prompt.dirty() && !session.working()} value={ - +
Stop ESC @@ -720,8 +736,8 @@ export const PromptInput: Component = (props) => { > diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx index 865d9b30f..082525e28 100644 --- a/packages/desktop/src/components/terminal.tsx +++ b/packages/desktop/src/components/terminal.tsx @@ -2,7 +2,7 @@ import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" import { ComponentProps, createEffect, onCleanup, onMount, splitProps } from "solid-js" import { useSDK } from "@/context/sdk" import { SerializeAddon } from "@/addons/serialize" -import { LocalPTY } from "@/context/session" +import { LocalPTY } from "@/context/terminal" import { usePrefersDark } from "@solid-primitives/media" export interface TerminalProps extends ComponentProps<"div"> { diff --git a/packages/desktop/src/context/command.tsx b/packages/desktop/src/context/command.tsx index b17a98270..26b03f980 100644 --- a/packages/desktop/src/context/command.tsx +++ b/packages/desktop/src/context/command.tsx @@ -138,7 +138,7 @@ function DialogCommand(props: { options: CommandOption[] }) { search={{ placeholder: "Search commands", autofocus: true }} emptyMessage="No commands found" items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)} - key={(x) => x.id} + key={(x) => x?.id} groupBy={(x) => x.category ?? ""} onSelect={(option) => { if (option) { diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 925bf4d4c..af71c6a00 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -1,5 +1,5 @@ -import { createStore } from "solid-js/store" -import { createMemo, onMount } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { batch, createMemo, onMount } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { makePersisted } from "@solid-primitives/storage" import { useGlobalSync } from "./global-sync" @@ -22,6 +22,11 @@ export function getAvatarColors(key?: string) { } } +type SessionTabs = { + active?: string + all: string[] +} + export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", init: () => { @@ -41,9 +46,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( review: { state: "pane" as "pane" | "tab", }, + sessionTabs: {} as Record, }), { - name: "layout.v2", + name: "layout.v3", }, ) @@ -155,6 +161,86 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("review", "state", "tab") }, }, + tabs(sessionKey: string) { + const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] }) + return { + tabs, + active: createMemo(() => tabs().active), + all: createMemo(() => tabs().all), + setActive(tab: string | undefined) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "active", tab) + } + }, + setAll(all: string[]) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all, active: undefined }) + } else { + setStore("sessionTabs", sessionKey, "all", all) + } + }, + async open(tab: string) { + if (tab === "chat") { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: undefined }) + } else { + setStore("sessionTabs", sessionKey, "active", undefined) + } + return + } + const current = store.sessionTabs[sessionKey] ?? { all: [] } + if (tab !== "review") { + if (!current.all.includes(tab)) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [tab], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "all", [...current.all, tab]) + setStore("sessionTabs", sessionKey, "active", tab) + } + return + } + } + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "active", tab) + } + }, + close(tab: string) { + const current = store.sessionTabs[sessionKey] + if (!current) return + batch(() => { + setStore( + "sessionTabs", + sessionKey, + "all", + current.all.filter((x) => x !== tab), + ) + if (current.active === tab) { + const index = current.all.findIndex((f) => f === tab) + const previous = current.all[Math.max(0, index - 1)] + setStore("sessionTabs", sessionKey, "active", previous) + } + }) + }, + move(tab: string, to: number) { + const current = store.sessionTabs[sessionKey] + if (!current) return + const index = current.all.findIndex((f) => f === tab) + if (index === -1) return + setStore( + "sessionTabs", + sessionKey, + "all", + produce((opened) => { + opened.splice(to, 0, opened.splice(index, 1)[0]) + }), + ) + }, + } + }, } }, }) diff --git a/packages/desktop/src/context/prompt.tsx b/packages/desktop/src/context/prompt.tsx new file mode 100644 index 000000000..c3b3bbace --- /dev/null +++ b/packages/desktop/src/context/prompt.tsx @@ -0,0 +1,100 @@ +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { batch, createMemo } from "solid-js" +import { makePersisted } from "@solid-primitives/storage" +import { useParams } from "@solidjs/router" +import { TextSelection } from "./local" + +interface PartBase { + content: string + start: number + end: number +} + +export interface TextPart extends PartBase { + type: "text" +} + +export interface FileAttachmentPart extends PartBase { + type: "file" + path: string + selection?: TextSelection +} + +export type ContentPart = TextPart | FileAttachmentPart +export type Prompt = ContentPart[] + +export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] + +export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { + if (promptA.length !== promptB.length) return false + for (let i = 0; i < promptA.length; i++) { + const partA = promptA[i] + const partB = promptB[i] + if (partA.type !== partB.type) return false + if (partA.type === "text" && partA.content !== (partB as TextPart).content) { + return false + } + if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { + return false + } + } + return true +} + +function cloneSelection(selection?: TextSelection) { + if (!selection) return undefined + return { ...selection } +} + +function clonePart(part: ContentPart): ContentPart { + if (part.type === "text") return { ...part } + return { + ...part, + selection: cloneSelection(part.selection), + } +} + +function clonePrompt(prompt: Prompt): Prompt { + return prompt.map(clonePart) +} + +export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({ + name: "Prompt", + init: () => { + const params = useParams() + const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`) + + const [store, setStore] = makePersisted( + createStore<{ + prompt: Prompt + cursor?: number + }>({ + prompt: clonePrompt(DEFAULT_PROMPT), + cursor: undefined, + }), + { + name: name(), + }, + ) + + return { + current: createMemo(() => store.prompt), + cursor: createMemo(() => store.cursor), + dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), + set(prompt: Prompt, cursorPosition?: number) { + const next = clonePrompt(prompt) + batch(() => { + setStore("prompt", next) + if (cursorPosition !== undefined) setStore("cursor", cursorPosition) + }) + }, + reset() { + batch(() => { + setStore("prompt", clonePrompt(DEFAULT_PROMPT)) + setStore("cursor", 0) + }) + }, + } + }, +}) diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx deleted file mode 100644 index 860c1a14f..000000000 --- a/packages/desktop/src/context/session.tsx +++ /dev/null @@ -1,321 +0,0 @@ -import { createStore, produce } from "solid-js/store" -import { createSimpleContext } from "@opencode-ai/ui/context" -import { batch, createEffect, createMemo } from "solid-js" -import { useSync } from "./sync" -import { makePersisted } from "@solid-primitives/storage" -import { TextSelection } from "./local" -import { pipe, sumBy } from "remeda" -import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" -import { useParams } from "@solidjs/router" -import { useSDK } from "./sdk" - -export type LocalPTY = { - id: string - title: string - rows?: number - cols?: number - buffer?: string - scrollY?: number -} - -export const { use: useSession, provider: SessionProvider } = createSimpleContext({ - name: "Session", - init: () => { - const sdk = useSDK() - const params = useParams() - const sync = useSync() - const name = createMemo(() => `${params.dir}/session${params.id ? "/" + params.id : ""}.v3`) - - const [store, setStore] = makePersisted( - createStore<{ - messageId?: string - tabs: { - active?: string - all: string[] - } - prompt: Prompt - cursor?: number - terminals: { - active?: string - all: LocalPTY[] - } - }>({ - tabs: { - all: [], - }, - prompt: clonePrompt(DEFAULT_PROMPT), - cursor: undefined, - terminals: { all: [] }, - }), - { - name: name(), - }, - ) - - createEffect(() => { - if (!params.id) return - sync.session.sync(params.id) - }) - - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) - const userMessages = createMemo(() => - messages() - .filter((m) => m.role === "user") - .sort((a, b) => a.id.localeCompare(b.id)), - ) - const lastUserMessage = createMemo(() => { - return userMessages()?.at(-1) - }) - const activeMessage = createMemo(() => { - if (!store.messageId) return lastUserMessage() - return userMessages()?.find((m) => m.id === store.messageId) - }) - const status = createMemo( - () => - sync.data.session_status[params.id ?? ""] ?? { - type: "idle", - }, - ) - const working = createMemo(() => status()?.type !== "idle") - - const cost = createMemo(() => { - const total = pipe( - messages(), - sumBy((x) => (x.role === "assistant" ? x.cost : 0)), - ) - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(total) - }) - - const last = createMemo( - () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage, - ) - const model = createMemo(() => - last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined, - ) - const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) - - const tokens = createMemo(() => { - if (!last()) return - const tokens = last().tokens - return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write - }) - - const context = createMemo(() => { - const total = tokens() - const limit = model()?.limit.context - if (!total || !limit) return 0 - return Math.round((total / limit) * 100) - }) - - return { - get id() { - return params.id - }, - info, - status, - working, - diffs, - prompt: { - current: createMemo(() => store.prompt), - cursor: createMemo(() => store.cursor), - dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), - set(prompt: Prompt, cursorPosition?: number) { - const next = clonePrompt(prompt) - batch(() => { - setStore("prompt", next) - if (cursorPosition !== undefined) setStore("cursor", cursorPosition) - }) - }, - }, - messages: { - all: messages, - user: userMessages, - last: lastUserMessage, - active: activeMessage, - setActive(message: UserMessage | undefined) { - setStore("messageId", message?.id) - }, - }, - usage: { - tokens, - cost, - context, - }, - layout: { - tabs: store.tabs, - setActiveTab(tab: string | undefined) { - setStore("tabs", "active", tab) - }, - setOpenedTabs(tabs: string[]) { - setStore("tabs", "all", tabs) - }, - async openTab(tab: string) { - if (tab === "chat") { - setStore("tabs", "active", undefined) - return - } - if (tab !== "review") { - if (!store.tabs.all.includes(tab)) { - setStore("tabs", "all", [...store.tabs.all, tab]) - } - } - setStore("tabs", "active", tab) - }, - closeTab(tab: string) { - batch(() => { - setStore( - "tabs", - "all", - store.tabs.all.filter((x) => x !== tab), - ) - if (store.tabs.active === tab) { - const index = store.tabs.all.findIndex((f) => f === tab) - const previous = store.tabs.all[Math.max(0, index - 1)] - setStore("tabs", "active", previous) - } - }) - }, - moveTab(tab: string, to: number) { - const index = store.tabs.all.findIndex((f) => f === tab) - if (index === -1) return - setStore( - "tabs", - "all", - produce((opened) => { - opened.splice(to, 0, opened.splice(index, 1)[0]) - }), - ) - }, - }, - terminal: { - all: createMemo(() => Object.values(store.terminals.all)), - active: createMemo(() => store.terminals.active), - new() { - sdk.client.pty.create({ title: `Terminal ${store.terminals.all.length + 1}` }).then((pty) => { - const id = pty.data?.id - if (!id) return - setStore("terminals", "all", [ - ...store.terminals.all, - { - id, - title: pty.data?.title ?? "Terminal", - }, - ]) - setStore("terminals", "active", id) - }) - }, - update(pty: Partial & { id: string }) { - setStore("terminals", "all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) - sdk.client.pty.update({ - ptyID: pty.id, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) - }, - async clone(id: string) { - const index = store.terminals.all.findIndex((x) => x.id === id) - const pty = store.terminals.all[index] - if (!pty) return - const clone = await sdk.client.pty.create({ - title: pty.title, - }) - if (!clone.data) return - setStore("terminals", "all", index, { - ...pty, - ...clone.data, - }) - if (store.terminals.active === pty.id) { - setStore("terminals", "active", clone.data.id) - } - }, - open(id: string) { - setStore("terminals", "active", id) - }, - async close(id: string) { - batch(() => { - setStore( - "terminals", - "all", - store.terminals.all.filter((x) => x.id !== id), - ) - if (store.terminals.active === id) { - const index = store.terminals.all.findIndex((f) => f.id === id) - const previous = store.tabs.all[Math.max(0, index - 1)] - setStore("terminals", "active", previous) - } - }) - await sdk.client.pty.remove({ ptyID: id }) - }, - move(id: string, to: number) { - const index = store.terminals.all.findIndex((f) => f.id === id) - if (index === -1) return - setStore( - "terminals", - "all", - produce((all) => { - all.splice(to, 0, all.splice(index, 1)[0]) - }), - ) - }, - }, - } - }, -}) - -interface PartBase { - content: string - start: number - end: number -} - -export interface TextPart extends PartBase { - type: "text" -} - -export interface FileAttachmentPart extends PartBase { - type: "file" - path: string - selection?: TextSelection -} - -export type ContentPart = TextPart | FileAttachmentPart -export type Prompt = ContentPart[] - -export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] - -export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { - if (promptA.length !== promptB.length) return false - for (let i = 0; i < promptA.length; i++) { - const partA = promptA[i] - const partB = promptB[i] - if (partA.type !== partB.type) return false - if (partA.type === "text" && partA.content !== (partB as TextPart).content) { - return false - } - if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { - return false - } - } - return true -} - -function cloneSelection(selection?: TextSelection) { - if (!selection) return undefined - return { ...selection } -} - -function clonePart(part: ContentPart): ContentPart { - if (part.type === "text") return { ...part } - return { - ...part, - selection: cloneSelection(part.selection), - } -} - -function clonePrompt(prompt: Prompt): Prompt { - return prompt.map(clonePart) -} diff --git a/packages/desktop/src/context/terminal.tsx b/packages/desktop/src/context/terminal.tsx new file mode 100644 index 000000000..cf9b5a5b9 --- /dev/null +++ b/packages/desktop/src/context/terminal.tsx @@ -0,0 +1,106 @@ +import { createStore, produce } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { batch, createMemo } from "solid-js" +import { makePersisted } from "@solid-primitives/storage" +import { useParams } from "@solidjs/router" +import { useSDK } from "./sdk" + +export type LocalPTY = { + id: string + title: string + rows?: number + cols?: number + buffer?: string + scrollY?: number +} + +export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({ + name: "Terminal", + init: () => { + const sdk = useSDK() + const params = useParams() + const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`) + + const [store, setStore] = makePersisted( + createStore<{ + active?: string + all: LocalPTY[] + }>({ + all: [], + }), + { + name: name(), + }, + ) + + return { + all: createMemo(() => Object.values(store.all)), + active: createMemo(() => store.active), + new() { + sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => { + const id = pty.data?.id + if (!id) return + setStore("all", [ + ...store.all, + { + id, + title: pty.data?.title ?? "Terminal", + }, + ]) + setStore("active", id) + }) + }, + update(pty: Partial & { id: string }) { + setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) + sdk.client.pty.update({ + ptyID: pty.id, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + }, + async clone(id: string) { + const index = store.all.findIndex((x) => x.id === id) + const pty = store.all[index] + if (!pty) return + const clone = await sdk.client.pty.create({ + title: pty.title, + }) + if (!clone.data) return + setStore("all", index, { + ...pty, + ...clone.data, + }) + if (store.active === pty.id) { + setStore("active", clone.data.id) + } + }, + open(id: string) { + setStore("active", id) + }, + async close(id: string) { + batch(() => { + setStore( + "all", + store.all.filter((x) => x.id !== id), + ) + if (store.active === id) { + const index = store.all.findIndex((f) => f.id === id) + const previous = store.all[Math.max(0, index - 1)] + setStore("active", previous?.id) + } + }) + await sdk.client.pty.remove({ ptyID: id }) + }, + move(id: string, to: number) { + const index = store.all.findIndex((f) => f.id === id) + if (index === -1) return + setStore( + "all", + produce((all) => { + all.splice(to, 0, all.splice(index, 1)[0]) + }), + ) + }, + } + }, +}) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index e3cac4842..48e01239c 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -27,22 +27,91 @@ import { import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" import type { JSX } from "solid-js" import { useSync } from "@/context/sync" -import { useSession, type LocalPTY } from "@/context/session" +import { useTerminal, type LocalPTY } from "@/context/terminal" import { useLayout } from "@/context/layout" +import { usePrompt } from "@/context/prompt" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Terminal } from "@/components/terminal" import { checksum } from "@opencode-ai/util/encode" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" import { useCommand } from "@/context/command" +import { useParams } from "@solidjs/router" +import { pipe, sumBy } from "remeda" +import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" export default function Page() { const layout = useLayout() const local = useLocal() const sync = useSync() - const session = useSession() + const terminal = useTerminal() + const prompt = usePrompt() const dialog = useDialog() const command = useCommand() + const params = useParams() + + // Session-specific derived state + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey())) + + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) + const userMessages = createMemo(() => + messages() + .filter((m) => m.role === "user") + .sort((a, b) => a.id.localeCompare(b.id)), + ) + const lastUserMessage = createMemo(() => userMessages()?.at(-1)) + + const [messageStore, setMessageStore] = createStore<{ messageId?: string }>({}) + const activeMessage = createMemo(() => { + if (!messageStore.messageId) return lastUserMessage() + return userMessages()?.find((m) => m.id === messageStore.messageId) + }) + const setActiveMessage = (message: UserMessage | undefined) => { + setMessageStore("messageId", message?.id) + } + + const status = createMemo( + () => + sync.data.session_status[params.id ?? ""] ?? { + type: "idle", + }, + ) + const working = createMemo(() => status()?.type !== "idle") + + const cost = createMemo(() => { + const total = pipe( + messages(), + sumBy((x) => (x.role === "assistant" ? x.cost : 0)), + ) + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(total) + }) + + const last = createMemo( + () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage, + ) + const model = createMemo(() => + last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined, + ) + const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) + + const tokens = createMemo(() => { + if (!last()) return + const t = last().tokens + return t.input + t.output + t.reasoning + t.cache.read + t.cache.write + }) + + const context = createMemo(() => { + const total = tokens() + const limit = model()?.limit.context + if (!total || !limit) return 0 + return Math.round((total / limit) * 100) + }) + const [store, setStore] = createStore({ clickTimer: undefined as number | undefined, activeDraggable: undefined as string | undefined, @@ -50,10 +119,15 @@ export default function Page() { }) let inputRef!: HTMLDivElement + createEffect(() => { + if (!params.id) return + sync.session.sync(params.id) + }) + createEffect(() => { if (layout.terminal.opened()) { - if (session.terminal.all().length === 0) { - session.terminal.new() + if (terminal.all().length === 0) { + terminal.new() } } }) @@ -99,7 +173,7 @@ export default function Page() { description: "Create a new terminal tab", category: "Terminal", keybind: "ctrl+shift+`", - onSelect: () => session.terminal.new(), + onSelect: () => terminal.new(), }, ]) @@ -166,11 +240,11 @@ export default function Page() { const handleDragOver = (event: DragEvent) => { const { draggable, droppable } = event if (draggable && droppable) { - const currentTabs = session.layout.tabs.all + const currentTabs = tabs().all() const fromIndex = currentTabs?.indexOf(draggable.id.toString()) const toIndex = currentTabs?.indexOf(droppable.id.toString()) if (fromIndex !== toIndex && toIndex !== undefined) { - session.layout.moveTab(draggable.id.toString(), toIndex) + tabs().move(draggable.id.toString(), toIndex) } } } @@ -188,11 +262,11 @@ export default function Page() { const handleTerminalDragOver = (event: DragEvent) => { const { draggable, droppable } = event if (draggable && droppable) { - const terminals = session.terminal.all() - const fromIndex = terminals.findIndex((t) => t.id === draggable.id.toString()) - const toIndex = terminals.findIndex((t) => t.id === droppable.id.toString()) + const terminals = terminal.all() + const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString()) + const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString()) if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { - session.terminal.move(draggable.id.toString(), toIndex) + terminal.move(draggable.id.toString(), toIndex) } } } @@ -210,8 +284,8 @@ export default function Page() { 1 && ( - session.terminal.close(props.terminal.id)} /> + terminal.all().length > 1 && ( + terminal.close(props.terminal.id)} /> ) } > @@ -326,7 +400,7 @@ export default function Page() { return typeof draggable.id === "string" ? draggable.id : undefined } - const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length) + const wide = createMemo(() => layout.review.state() === "tab" || !diffs().length) return (
@@ -339,7 +413,7 @@ export default function Page() { > - +
@@ -349,15 +423,15 @@ export default function Page() { value={`${new Intl.NumberFormat("en-US", { notation: "compact", compactDisplay: "short", - }).format(session.usage.tokens() ?? 0)} Tokens`} + }).format(tokens() ?? 0)} Tokens`} class="flex items-center gap-1.5" > - -
{session.usage.context() ?? 0}%
+ +
{context() ?? 0}%
- +
- - + +
Review
- +
- {session.info()?.summary?.files ?? 0} + {info()?.summary?.files ?? 0}
- - - {(tab) => ( - - )} + + + {(tab) => }
@@ -415,27 +487,23 @@ export default function Page() { }} > - +
1 - ? "pr-6 pl-18" - : "px-6"), + (wide() ? "max-w-146 mx-auto px-6" : userMessages().length > 1 ? "pr-6 pl-18" : "px-6"), }} />
@@ -476,7 +544,7 @@ export default function Page() {
- +
{ layout.review.tab() - session.layout.setActiveTab("review") + tabs().setActive("review") }} /> @@ -506,7 +574,7 @@ export default function Page() {
- +
- + {(tab) => { const [file] = createResource( () => tab, @@ -579,7 +647,7 @@ export default function Page() {
- +
{ @@ -639,25 +707,21 @@ export default function Page() { > - + - t.id)}> - {(terminal) => } + t.id)}> + {(pty) => }
- +
- - {(terminal) => ( - - session.terminal.clone(terminal.id)} - /> + + {(pty) => ( + + terminal.clone(pty.id)} /> )} @@ -665,9 +729,9 @@ export default function Page() { {(draggedId) => { - const terminal = createMemo(() => session.terminal.all().find((t) => t.id === draggedId())) + const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId())) return ( - + {(t) => (
{t().title} -- cgit v1.2.3 From 88c06751487291416eb473831cda70455f67412a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:41:37 -0600 Subject: wip(desktop): progress --- packages/desktop/src/components/prompt-input.tsx | 21 +++------------------ packages/desktop/src/pages/layout.tsx | 3 +++ 2 files changed, 6 insertions(+), 18 deletions(-) (limited to 'packages/desktop/src/components') diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index a498593bd..fd5574990 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -93,13 +93,11 @@ export const PromptInput: Component = (props) => { historyIndex: number savedPrompt: Prompt | null placeholder: number - slashFilter: string }>({ popover: null, historyIndex: -1, savedPrompt: null, placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), - slashFilter: "", }) const MAX_HISTORY = 100 @@ -237,25 +235,12 @@ export const PromptInput: Component = (props) => { onInput: slashOnInput, onKeyDown: slashOnKeyDown, } = useFilteredList({ - items: () => { - const filter = store.slashFilter.toLowerCase() - return slashCommands().filter( - (cmd) => - cmd.trigger.toLowerCase().includes(filter) || - cmd.title.toLowerCase().includes(filter) || - cmd.description?.toLowerCase().includes(filter) || - false, - ) - }, + items: slashCommands, key: (x) => x?.id, + filterKeys: ["trigger", "title", "description"], onSelect: handleSlashSelect, }) - // Update slash filter when store changes - createEffect(() => { - slashOnInput(store.slashFilter) - }) - createEffect( on( () => prompt.current(), @@ -337,7 +322,7 @@ export const PromptInput: Component = (props) => { onInput(atMatch[1]) setStore("popover", "file") } else if (slashMatch) { - setStore("slashFilter", slashMatch[1]) + slashOnInput(slashMatch[1]) setStore("popover", "slash") } else { setStore("popover", null) diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 965ade9f8..08d24dc6f 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -299,6 +299,9 @@ export default function Layout(props: ParentProps) { if (match.found) draft.session.splice(match.index, 1) }), ) + if (session.id === params.id) { + navigate(`/${params.dir}/session`) + } } return (
Date: Mon, 15 Dec 2025 06:06:05 -0600 Subject: wip(desktop): progress --- packages/desktop/src/components/prompt-input.tsx | 6 +-- packages/desktop/src/pages/session.tsx | 64 ++++++++++-------------- 2 files changed, 29 insertions(+), 41 deletions(-) (limited to 'packages/desktop/src/components') diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index fd5574990..4dd8d878c 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -591,8 +591,8 @@ export const PromptInput: Component = (props) => { {/* Popover for file mentions and slash commands */}
@@ -602,7 +602,7 @@ export const PromptInput: Component = (props) => { {(i) => (
- - {formatKeybind(cmd.keybind!)} - +
+ + + custom + + + + {formatKeybind(cmd.keybind!)} + +
)} diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx index 8151a2c6f..b90dde34f 100644 --- a/packages/desktop/src/context/global-sync.tsx +++ b/packages/desktop/src/context/global-sync.tsx @@ -13,6 +13,7 @@ import { type SessionStatus, type ProviderListResponse, type ProviderAuthResponse, + type Command, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -24,6 +25,7 @@ import { onMount } from "solid-js" type State = { ready: boolean agent: Agent[] + command: Command[] project: string provider: ProviderListResponse config: Config @@ -79,6 +81,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple path: { state: "", config: "", worktree: "", directory: "", home: "" }, ready: false, agent: [], + command: [], session: [], session_status: {}, session_diff: {}, @@ -118,6 +121,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)), path: () => sdk.path.get().then((x) => setStore("path", x.data!)), agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), + command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])), session: () => loadSessions(directory), status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)), config: () => sdk.config.get().then((x) => setStore("config", x.data!)), -- cgit v1.2.3 From df2713a6c263a006539efad84e64103caee2d3f5 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 07:14:03 -0600 Subject: chore: cleanup --- packages/desktop/src/components/prompt-input.tsx | 21 ++++++--------------- packages/desktop/src/context/command.tsx | 17 ----------------- packages/desktop/src/pages/session.tsx | 19 +++---------------- packages/ui/src/components/session-turn.tsx | 1 - 4 files changed, 9 insertions(+), 49 deletions(-) (limited to 'packages/desktop/src/components') diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 87f91104c..9be09507a 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -77,7 +77,6 @@ export const PromptInput: Component = (props) => { const command = useCommand() let editorRef!: HTMLDivElement - // Session-derived state const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) @@ -183,10 +182,10 @@ export const PromptInput: Component = (props) => { } onMount(() => { - editorRef?.addEventListener("paste", handlePaste) + editorRef.addEventListener("paste", handlePaste) }) onCleanup(() => { - editorRef?.removeEventListener("paste", handlePaste) + editorRef.removeEventListener("paste", handlePaste) }) createEffect(() => { @@ -208,7 +207,6 @@ export const PromptInput: Component = (props) => { onSelect: handleFileSelect, }) - // Get slash commands from registered commands (only those with explicit slash trigger) const slashCommands = createMemo(() => { const builtin = command.options .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) @@ -237,12 +235,10 @@ export const PromptInput: Component = (props) => { setStore("popover", null) if (cmd.type === "custom") { - // For custom commands, insert the command text so user can add arguments const text = `/${cmd.trigger} ` editorRef.innerHTML = "" editorRef.textContent = text prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) - // Set cursor at end requestAnimationFrame(() => { editorRef.focus() const range = document.createRange() @@ -255,7 +251,6 @@ export const PromptInput: Component = (props) => { return } - // For built-in commands, clear input and execute immediately editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) command.trigger(cmd.id, "slash") @@ -287,7 +282,7 @@ export const PromptInput: Component = (props) => { } editorRef.innerHTML = "" - currentParts.forEach((part: ContentPart) => { + currentParts.forEach((part) => { if (part.type === "text") { editorRef.appendChild(document.createTextNode(part.content)) } else if (part.type === "file") { @@ -374,7 +369,7 @@ export const PromptInput: Component = (props) => { const cursorPosition = getCursorPosition(editorRef) const currentPrompt = prompt.current() - const rawText = currentPrompt.map((p: ContentPart) => p.content).join("") + const rawText = currentPrompt.map((p) => p.content).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) @@ -498,7 +493,6 @@ export const PromptInput: Component = (props) => { } const handleKeyDown = (event: KeyboardEvent) => { - // Handle popover navigation if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { if (store.popover === "file") { onKeyDown(event) @@ -510,7 +504,6 @@ export const PromptInput: Component = (props) => { } if (event.key === "ArrowUp" || event.key === "ArrowDown") { - // Skip history navigation when modifier keys are pressed (used for other commands) if (event.altKey || event.ctrlKey || event.metaKey) return const { collapsed, onFirstLine, onLastLine } = getCaretLineState() if (!collapsed) return @@ -554,7 +547,7 @@ export const PromptInput: Component = (props) => { const handleSubmit = async (event: Event) => { event.preventDefault() const currentPrompt = prompt.current() - const text = currentPrompt.map((part: ContentPart) => part.content).join("") + const text = currentPrompt.map((part) => part.content).join("") if (text.trim().length === 0) { if (working()) abort() return @@ -574,7 +567,7 @@ export const PromptInput: Component = (props) => { const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) const attachments = currentPrompt.filter( - (part: ContentPart) => part.type === "file", + (part) => part.type === "file", ) as import("@/context/prompt").FileAttachmentPart[] const attachmentParts = attachments.map((attachment) => { @@ -603,7 +596,6 @@ export const PromptInput: Component = (props) => { editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) - // Check if this is a custom command if (text.startsWith("/")) { const [cmdName, ...args] = text.split(" ") const commandName = cmdName.slice(1) // Remove leading "/" @@ -639,7 +631,6 @@ export const PromptInput: Component = (props) => { return (
- {/* Popover for file mentions and slash commands */}
void } @@ -197,7 +182,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const handleKeyDown = (event: KeyboardEvent) => { if (suspended()) return - // Check for command palette keybind (mod+shift+p) const paletteKeybinds = parseKeybind("mod+shift+p") if (matchKeybind(paletteKeybinds, event)) { event.preventDefault() @@ -205,7 +189,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex return } - // Check registered command keybinds for (const option of options()) { if (option.disabled) continue if (!option.keybind) continue diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index d49779587..9e743e48f 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -49,7 +49,6 @@ export default function Page() { const params = useParams() const navigate = useNavigate() - // Session-specific derived state const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) @@ -132,7 +131,6 @@ export default function Page() { } }) - // Register commands for this page command.register(() => [ { id: "session.new", @@ -230,28 +228,17 @@ export default function Page() { }, ]) - // Handle keyboard events that aren't commands const handleKeyDown = (event: KeyboardEvent) => { - // Don't interfere with terminal // @ts-expect-error - if (document.activeElement?.dataset?.component === "terminal") { - return - } - - // Don't interfere with dialogs - if (dialog.stack.length > 0) { - return - } + if (document.activeElement?.dataset?.component === "terminal") return + if (dialog.stack.length > 0) return const focused = document.activeElement === inputRef if (focused) { - if (event.key === "Escape") { - inputRef?.blur() - } + if (event.key === "Escape") inputRef?.blur() return } - // Focus input when typing characters if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { inputRef?.focus() } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 807092d03..f905abbd1 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -228,7 +228,6 @@ export function SessionTurn( duration: duration(), }) - // Sync with controlled prop createEffect(() => { if (props.stepsExpanded !== undefined) { setStore("stepsExpanded", props.stepsExpanded) -- cgit v1.2.3 From 44d6c5780d41616bf29a749020c9d7f98895407f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 07:25:24 -0600 Subject: wip(desktop): progress --- packages/desktop/src/components/prompt-input.tsx | 47 ++++++++---------------- 1 file changed, 16 insertions(+), 31 deletions(-) (limited to 'packages/desktop/src/components') diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 9be09507a..37d05c311 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -133,31 +133,20 @@ export const PromptInput: Component = (props) => { }) } - const getCaretLineState = () => { + const getCaretState = () => { const selection = window.getSelection() - if (!selection || selection.rangeCount === 0) return { collapsed: false, onFirstLine: false, onLastLine: false } - const range = selection.getRangeAt(0) - const rect = range.getBoundingClientRect() - const editorRect = editorRef.getBoundingClientRect() - const style = window.getComputedStyle(editorRef) - const paddingTop = parseFloat(style.paddingTop) || 0 - const paddingBottom = parseFloat(style.paddingBottom) || 0 - let lineHeight = parseFloat(style.lineHeight) - if (!Number.isFinite(lineHeight)) lineHeight = parseFloat(style.fontSize) || 16 - const scrollTop = editorRef.scrollTop - let relativeTop = rect.top - editorRect.top - paddingTop + scrollTop - if (!Number.isFinite(relativeTop)) relativeTop = scrollTop - relativeTop = Math.max(0, relativeTop) - let caretHeight = rect.height - if (!caretHeight || !Number.isFinite(caretHeight)) caretHeight = lineHeight - const relativeBottom = relativeTop + caretHeight - const contentHeight = Math.max(caretHeight, editorRef.scrollHeight - paddingTop - paddingBottom) - const threshold = Math.max(2, lineHeight / 2) - + const textLength = promptLength(prompt.current()) + if (!selection || selection.rangeCount === 0) { + return { collapsed: false, cursorPosition: 0, textLength } + } + const anchorNode = selection.anchorNode + if (!anchorNode || !editorRef.contains(anchorNode)) { + return { collapsed: false, cursorPosition: 0, textLength } + } return { collapsed: selection.isCollapsed, - onFirstLine: relativeTop <= threshold, - onLastLine: contentHeight - relativeBottom <= threshold, + cursorPosition: getCursorPosition(editorRef), + textLength, } } @@ -505,17 +494,13 @@ export const PromptInput: Component = (props) => { if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (event.altKey || event.ctrlKey || event.metaKey) return - const { collapsed, onFirstLine, onLastLine } = getCaretLineState() + const { collapsed, cursorPosition, textLength } = getCaretState() if (!collapsed) return - const cursorPos = getCursorPosition(editorRef) - const textLength = promptLength(prompt.current()) const inHistory = store.historyIndex >= 0 - const isStart = cursorPos === 0 - const isEnd = cursorPos === textLength - const atAbsoluteStart = onFirstLine && isStart - const atAbsoluteEnd = onLastLine && isEnd - const allowUp = (inHistory && isEnd) || atAbsoluteStart - const allowDown = (inHistory && isStart) || atAbsoluteEnd + const atAbsoluteStart = cursorPosition === 0 + const atAbsoluteEnd = cursorPosition === textLength + const allowUp = (inHistory && atAbsoluteEnd) || atAbsoluteStart + const allowDown = (inHistory && atAbsoluteStart) || atAbsoluteEnd if (event.key === "ArrowUp") { if (!allowUp) return -- cgit v1.2.3 From 5cf6a1343c6ca088bd2b586197faf7fe58961290 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 09:34:00 -0600 Subject: wip(desktop): progress --- packages/desktop/src/components/prompt-input.tsx | 191 +++++++++++++++++--- packages/desktop/src/context/global-sync.tsx | 40 ++++- packages/desktop/src/context/local.tsx | 2 +- packages/desktop/src/context/prompt.tsx | 14 +- packages/desktop/src/pages/layout.tsx | 211 +++++++++++++++-------- packages/desktop/src/pages/session.tsx | 148 +++++++++++----- packages/desktop/src/utils/prompt.ts | 47 +++++ packages/ui/src/components/message-part.css | 76 +++++++- packages/ui/src/components/message-part.tsx | 96 ++++++++++- packages/ui/src/components/session-turn.tsx | 10 +- 10 files changed, 676 insertions(+), 159 deletions(-) create mode 100644 packages/desktop/src/utils/prompt.ts (limited to 'packages/desktop/src/components') diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 37d05c311..f3f758102 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,10 +1,10 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js" -import { createStore } from "solid-js/store" +import { createStore, produce } from "solid-js/store" import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" -import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt } from "@/context/prompt" +import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" import { useNavigate, useParams } from "@solidjs/router" @@ -22,6 +22,9 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid import { useProviders } from "@/hooks/use-providers" import { useCommand, formatKeybind } from "@/context/command" +const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] +const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] + interface PromptInputProps { class?: string ref?: (el: HTMLDivElement) => void @@ -93,11 +96,15 @@ export const PromptInput: Component = (props) => { historyIndex: number savedPrompt: Prompt | null placeholder: number + dragging: boolean + imageAttachments: ImageAttachmentPart[] }>({ popover: null, historyIndex: -1, savedPrompt: null, placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), + dragging: false, + imageAttachments: [], }) const MAX_HISTORY = 100 @@ -113,16 +120,17 @@ export const PromptInput: Component = (props) => { ) const clonePromptParts = (prompt: Prompt): Prompt => - prompt.map((part) => - part.type === "text" - ? { ...part } - : { - ...part, - selection: part.selection ? { ...part.selection } : undefined, - }, - ) + prompt.map((part) => { + if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } + return { + ...part, + selection: part.selection ? { ...part.selection } : undefined, + } + }) - const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0) + const promptLength = (prompt: Prompt) => + prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0) const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const length = position === "start" ? 0 : promptLength(p) @@ -162,14 +170,89 @@ export const PromptInput: Component = (props) => { const isFocused = createFocusSignal(() => editorRef) - const handlePaste = (event: ClipboardEvent) => { + const addImageAttachment = async (file: File) => { + if (!ACCEPTED_FILE_TYPES.includes(file.type)) return + + const reader = new FileReader() + reader.onload = () => { + const dataUrl = reader.result as string + const attachment: ImageAttachmentPart = { + type: "image", + id: crypto.randomUUID(), + filename: file.name, + mime: file.type, + dataUrl, + } + setStore( + produce((draft) => { + draft.imageAttachments.push(attachment) + }), + ) + } + reader.readAsDataURL(file) + } + + const removeImageAttachment = (id: string) => { + setStore( + produce((draft) => { + draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id) + }), + ) + } + + const handlePaste = async (event: ClipboardEvent) => { + const clipboardData = event.clipboardData + if (!clipboardData) return + + const items = Array.from(clipboardData.items) + const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) + + if (imageItems.length > 0) { + event.preventDefault() + event.stopPropagation() + for (const item of imageItems) { + const file = item.getAsFile() + if (file) await addImageAttachment(file) + } + return + } + event.preventDefault() event.stopPropagation() - // @ts-expect-error - const plainText = (event.clipboardData || window.clipboardData)?.getData("text/plain") ?? "" + const plainText = clipboardData.getData("text/plain") ?? "" addPart({ type: "text", content: plainText, start: 0, end: 0 }) } + const handleDragOver = (event: DragEvent) => { + event.preventDefault() + const hasFiles = event.dataTransfer?.types.includes("Files") + if (hasFiles) { + setStore("dragging", true) + } + } + + const handleDragLeave = (event: DragEvent) => { + const related = event.relatedTarget as Node | null + const form = event.currentTarget as HTMLElement + if (!related || !form.contains(related)) { + setStore("dragging", false) + } + } + + const handleDrop = async (event: DragEvent) => { + event.preventDefault() + setStore("dragging", false) + + const files = event.dataTransfer?.files + if (!files) return + + for (const file of Array.from(files)) { + if (ACCEPTED_FILE_TYPES.includes(file.type)) { + await addImageAttachment(file) + } + } + } + onMount(() => { editorRef.addEventListener("paste", handlePaste) }) @@ -328,7 +411,7 @@ export const PromptInput: Component = (props) => { const handleInput = () => { const rawParts = parseFromDOM() const cursorPosition = getCursorPosition(editorRef) - const rawText = rawParts.map((p) => p.content).join("") + const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("") const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) // Slash commands only trigger when / is at the start of input @@ -358,7 +441,7 @@ export const PromptInput: Component = (props) => { const cursorPosition = getCursorPosition(editorRef) const currentPrompt = prompt.current() - const rawText = currentPrompt.map((p) => p.content).join("") + const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) @@ -424,7 +507,7 @@ export const PromptInput: Component = (props) => { const addToHistory = (prompt: Prompt) => { const text = prompt - .map((p) => p.content) + .map((p) => ("content" in p ? p.content : "")) .join("") .trim() if (!text) return @@ -432,7 +515,7 @@ export const PromptInput: Component = (props) => { const entry = clonePromptParts(prompt) const lastEntry = history.entries[0] if (lastEntry) { - const lastText = lastEntry.map((p) => p.content).join("") + const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("") if (lastText === text) return } @@ -532,8 +615,9 @@ export const PromptInput: Component = (props) => { const handleSubmit = async (event: Event) => { event.preventDefault() const currentPrompt = prompt.current() - const text = currentPrompt.map((part) => part.content).join("") - if (text.trim().length === 0) { + const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") + const hasImageAttachments = store.imageAttachments.length > 0 + if (text.trim().length === 0 && !hasImageAttachments) { if (working()) abort() return } @@ -555,7 +639,7 @@ export const PromptInput: Component = (props) => { (part) => part.type === "file", ) as import("@/context/prompt").FileAttachmentPart[] - const attachmentParts = attachments.map((attachment) => { + const fileAttachmentParts = attachments.map((attachment) => { const absolute = toAbsolutePath(attachment.path) const query = attachment.selection ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` @@ -577,9 +661,17 @@ export const PromptInput: Component = (props) => { } }) + const imageAttachmentParts = store.imageAttachments.map((attachment) => ({ + type: "file" as const, + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + })) + tabs().setActive(undefined) editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + setStore("imageAttachments", []) if (text.startsWith("/")) { const [cmdName, ...args] = text.split(" ") @@ -609,7 +701,8 @@ export const PromptInput: Component = (props) => { type: "text", text, }, - ...attachmentParts, + ...fileAttachmentParts, + ...imageAttachmentParts, ], }) } @@ -686,12 +779,58 @@ export const PromptInput: Component = (props) => { + +
+
+ + Drop images or PDFs here +
+
+
+ 0}> +
+ + {(attachment) => ( +
+ + +
+ } + > + {attachment.filename} + + +
+ {attachment.filename} +
+
+ )} + +
+
{ @@ -706,7 +845,7 @@ export const PromptInput: Component = (props) => { "[&>[data-type=file]]:text-icon-info-active": true, }} /> - +
Ask anything... "{PLACEHOLDERS[store.placeholder]}"
@@ -735,7 +874,7 @@ export const PromptInput: Component = (props) => {
@@ -755,7 +894,7 @@ export const PromptInput: Component = (props) => { > { - const sessions = (x.data ?? []) + const oneHourAgo = Date.now() - 60 * 60 * 1000 + const nonArchived = (x.data ?? []) .slice() .filter((s) => !s.time.archived) .sort((a, b) => a.id.localeCompare(b.id)) - .slice(0, 5) + // Include at least 5 sessions, plus any updated in the last hour + const sessions = nonArchived.filter((s, i) => { + if (i < 5) return true + const updated = new Date(s.time.updated).getTime() + return updated > oneHourAgo + }) const [, setStore] = child(directory) setStore("session", sessions) }) @@ -220,6 +226,21 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple ) break } + case "message.removed": { + const messages = store.message[event.properties.sessionID] + if (!messages) break + const result = Binary.search(messages, event.properties.messageID, (m) => m.id) + if (result.found) { + setStore( + "message", + event.properties.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + break + } case "message.part.updated": { const part = event.properties.part const parts = store.part[part.messageID] @@ -241,6 +262,21 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple ) break } + case "message.part.removed": { + const parts = store.part[event.properties.messageID] + if (!parts) break + const result = Binary.search(parts, event.properties.partID, (p) => p.id) + if (result.found) { + setStore( + "part", + event.properties.messageID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + break + } } }) diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 6ec9778cc..b12679210 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -406,7 +406,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ case "file.watcher.updated": const relativePath = relative(event.properties.file) if (relativePath.startsWith(".git/")) return - load(relativePath) + if (store.node[relativePath]) load(relativePath) break } }) diff --git a/packages/desktop/src/context/prompt.tsx b/packages/desktop/src/context/prompt.tsx index c3b3bbace..2da0a08d5 100644 --- a/packages/desktop/src/context/prompt.tsx +++ b/packages/desktop/src/context/prompt.tsx @@ -21,7 +21,15 @@ export interface FileAttachmentPart extends PartBase { selection?: TextSelection } -export type ContentPart = TextPart | FileAttachmentPart +export interface ImageAttachmentPart { + type: "image" + id: string + filename: string + mime: string + dataUrl: string +} + +export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart export type Prompt = ContentPart[] export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] @@ -38,6 +46,9 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { return false } + if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) { + return false + } } return true } @@ -49,6 +60,7 @@ function cloneSelection(selection?: TextSelection) { function clonePart(part: ContentPart): ContentPart { if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } return { ...part, selection: cloneSelection(part.selection), diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 53078e01b..6632abe3a 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -55,10 +55,32 @@ export default function Layout(props: ParentProps) { const dialog = useDialog() const command = useCommand() + function flattenSessions(sessions: Session[]): Session[] { + const childrenMap = new Map() + for (const session of sessions) { + if (session.parentID) { + const children = childrenMap.get(session.parentID) ?? [] + children.push(session) + childrenMap.set(session.parentID, children) + } + } + const result: Session[] = [] + function visit(session: Session) { + result.push(session) + for (const child of childrenMap.get(session.id) ?? []) { + visit(child) + } + } + for (const session of sessions) { + if (!session.parentID) visit(session) + } + return result + } + const currentSessions = createMemo(() => { if (!params.dir) return [] const directory = base64Decode(params.dir) - return globalSync.child(directory)[0].session ?? [] + return flattenSessions(globalSync.child(directory)[0].session ?? []) }) function navigateSessionByOffset(offset: number) { @@ -98,7 +120,7 @@ export default function Layout(props: ParentProps) { const nextProject = projects[nextProjectIndex] if (!nextProject) return - const nextProjectSessions = globalSync.child(nextProject.worktree)[0].session ?? [] + const nextProjectSessions = flattenSessions(globalSync.child(nextProject.worktree)[0].session ?? []) if (nextProjectSessions.length === 0) { // Navigate to the project's new session page if no sessions navigateToProject(nextProject.worktree) @@ -375,6 +397,98 @@ export default function Layout(props: ParentProps) { ) } + const SessionItem = (props: { + session: Session + slug: string + project: Project + depth?: number + childrenMap: Map + }): JSX.Element => { + const notification = useNotification() + const depth = props.depth ?? 0 + const children = createMemo(() => props.childrenMap.get(props.session.id) ?? []) + const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) + const notifications = createMemo(() => notification.session.unseen(props.session.id)) + const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const isWorking = createMemo( + () => + props.session.id !== params.id && + globalSync.child(props.project.worktree)[0].session_status[props.session.id]?.type === "busy", + ) + return ( + <> +
+ + +
+ + {props.session.title} + + + + {(child) => ( + + )} + + + ) + } + const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => { const notification = useNotification() const sortable = createSortable(props.project.worktree) @@ -382,6 +496,18 @@ export default function Layout(props: ParentProps) { const name = createMemo(() => getFilename(props.project.worktree)) const [store, setStore] = globalSync.child(props.project.worktree) const sessions = createMemo(() => store.session ?? []) + const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) + const childSessionsByParent = createMemo(() => { + const map = new Map() + for (const session of sessions()) { + if (session.parentID) { + const children = map.get(session.parentID) ?? [] + children.push(session) + map.set(session.parentID, children) + } + } + return map + }) const [expanded, setExpanded] = createSignal(true) return ( // @ts-ignore @@ -421,78 +547,17 @@ export default function Layout(props: ParentProps) {
diff --git a/packages/tauri/src-tauri/src/lib.rs b/packages/tauri/src-tauri/src/lib.rs index b06ccd06c..aab2ce5f3 100644 --- a/packages/tauri/src-tauri/src/lib.rs +++ b/packages/tauri/src-tauri/src/lib.rs @@ -183,6 +183,7 @@ pub fn run() { .inner_size(size.width as f64, size.height as f64) .decorations(true) .zoom_hotkeys_enabled(true) + .disable_drag_drop_handler() .initialization_script(format!( r#" window.__OPENCODE__ ??= {{}}; diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 0dbd7a650..b8e8106e8 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -51,6 +51,7 @@ const icons = { "circle-check": ``, copy: ``, check: ``, + photo: ``, } export interface IconProps extends ComponentProps<"svg"> { -- cgit v1.2.3 From dbbcf0b8d041245a8b8feeff159d0bb9801b9924 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 15 Dec 2025 18:14:30 -0500 Subject: tui: fix model selection dialog to properly replace current dialog instead of creating nested dialogs --- packages/desktop/src/components/prompt-input.tsx | 4 +- packages/desktop/src/context/notification.tsx | 1 + packages/ui/src/context/dialog.tsx | 70 +++++++++++++----------- 3 files changed, 41 insertions(+), 34 deletions(-) (limited to 'packages/desktop/src/components') diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 840710152..f2821c3c7 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -864,7 +864,9 @@ export const PromptInput: Component = (props) => { as="div" variant="ghost" onClick={() => - dialog.push(() => (providers.paid().length > 0 ? : )) + dialog.replace(() => + providers.paid().length > 0 ? : , + ) } > {local.model.current()?.name ?? "Select model"} diff --git a/packages/desktop/src/context/notification.tsx b/packages/desktop/src/context/notification.tsx index 9843066ea..839ebfad7 100644 --- a/packages/desktop/src/context/notification.tsx +++ b/packages/desktop/src/context/notification.tsx @@ -58,6 +58,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi time: Date.now(), viewed: false, } + console.log(event) switch (event.type) { case "session.idle": { const sessionID = event.properties.sessionID diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index fae0c57b4..50e41c596 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -1,7 +1,9 @@ import { createContext, + createEffect, createMemo, createSignal, + For, getOwner, Owner, ParentProps, @@ -11,6 +13,7 @@ import { type JSX, } from "solid-js" import { Dialog as Kobalte } from "@kobalte/core/dialog" +import { iife } from "@opencode-ai/util/iife" type DialogElement = () => JSX.Element @@ -25,23 +28,49 @@ function init() { }[] >([]) - return { + const result = { get stack() { return store() }, - push(element: DialogElement, owner: Owner, onClose?: () => void) { - setStore((s) => [...s, { element, onClose, owner }]) - }, pop() { const current = store().at(-1) + if (!current) return current?.onClose?.() - setStore((stack) => stack.slice(0, -1)) + setStore((stack) => { + stack.pop() + return [...stack] + }) }, replace(element: DialogElement, owner: Owner, onClose?: () => void) { for (const item of store()) { item.onClose?.() } - setStore([{ element, onClose, owner }]) + setStore([ + { + element: () => + runWithOwner(owner, () => ( + + { + if (!open) { + onClose?.() + result.pop() + } + }} + > + + + {element()} + + + + )), + onClose, + owner, + }, + ]) }, clear() { for (const item of store()) { @@ -50,38 +79,16 @@ function init() { setStore([]) }, } + return result } export function DialogProvider(props: ParentProps) { const ctx = init() - const last = createMemo(() => ctx.stack.at(-1)) return ( {props.children}
- - {(item) => - runWithOwner(item().owner, () => { - return ( - { - if (!open) { - item().onClose?.() - ctx.pop() - } - }} - > - - - {item().element()} - - - ) - }) - } - + {(item) => <>{item.element()}}
) @@ -103,9 +110,6 @@ export function useDialog() { replace(element: DialogElement, onClose?: () => void) { ctx.replace(element, owner, onClose) }, - push(element: DialogElement, onClose?: () => void) { - ctx.push(element, owner, onClose) - }, pop() { ctx.pop() }, -- cgit v1.2.3 From 112c58abf516e4523fb698b868a710c40b271559 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 15 Dec 2025 19:33:17 -0500 Subject: tui: refactor dialog system to use single active dialog instead of stack --- .../src/components/dialog-connect-provider.tsx | 22 ++-- .../desktop/src/components/dialog-select-file.tsx | 2 +- .../src/components/dialog-select-model-unpaid.tsx | 6 +- .../desktop/src/components/dialog-select-model.tsx | 6 +- .../src/components/dialog-select-provider.tsx | 2 +- packages/desktop/src/components/prompt-input.tsx | 4 +- packages/desktop/src/context/command.tsx | 6 +- packages/desktop/src/context/notification.tsx | 2 +- packages/desktop/src/pages/layout.tsx | 2 +- packages/desktop/src/pages/session.tsx | 8 +- packages/ui/src/context/dialog.tsx | 122 +++++++++------------ 11 files changed, 78 insertions(+), 104 deletions(-) (limited to 'packages/desktop/src/components') diff --git a/packages/desktop/src/components/dialog-connect-provider.tsx b/packages/desktop/src/components/dialog-connect-provider.tsx index 4660e1398..0d6737815 100644 --- a/packages/desktop/src/components/dialog-connect-provider.tsx +++ b/packages/desktop/src/components/dialog-connect-provider.tsx @@ -108,20 +108,18 @@ export function DialogConnectProvider(props: { provider: string }) { async function complete() { await globalSDK.client.global.dispose() - setTimeout(() => { - showToast({ - variant: "success", - icon: "circle-check", - title: `${provider().name} connected`, - description: `${provider().name} models are now available to use.`, - }) - dialog.replace(() => ) - }, 1000) + dialog.close() + showToast({ + variant: "success", + icon: "circle-check", + title: `${provider().name} connected`, + description: `${provider().name} models are now available to use.`, + }) } function goBack() { if (methods().length === 1) { - dialog.replace(() => ) + dialog.show(() => ) return } if (store.authorization) { @@ -133,7 +131,7 @@ export function DialogConnectProvider(props: { provider: string }) { setStore("methodIndex", undefined) return } - dialog.replace(() => ) + dialog.show(() => ) } return ( @@ -352,7 +350,7 @@ export function DialogConnectProvider(props: { provider: string }) { }) if (result.error) { // TODO: show error - dialog.clear() + dialog.close() return } await complete() diff --git a/packages/desktop/src/components/dialog-select-file.tsx b/packages/desktop/src/components/dialog-select-file.tsx index b719e15d2..61c518719 100644 --- a/packages/desktop/src/components/dialog-select-file.tsx +++ b/packages/desktop/src/components/dialog-select-file.tsx @@ -27,7 +27,7 @@ export function DialogSelectFile() { if (path) { tabs().open("file://" + path) } - dialog.clear() + dialog.close() }} > {(i) => ( diff --git a/packages/desktop/src/components/dialog-select-model-unpaid.tsx b/packages/desktop/src/components/dialog-select-model-unpaid.tsx index 7cdb24915..77e493d3c 100644 --- a/packages/desktop/src/components/dialog-select-model-unpaid.tsx +++ b/packages/desktop/src/components/dialog-select-model-unpaid.tsx @@ -42,7 +42,7 @@ export const DialogSelectModelUnpaid: Component = () => { local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true, }) - dialog.clear() + dialog.close() }} > {(i) => ( @@ -75,7 +75,7 @@ export const DialogSelectModelUnpaid: Component = () => { }} onSelect={(x) => { if (!x) return - dialog.replace(() => ) + dialog.show(() => ) }} > {(i) => ( @@ -105,7 +105,7 @@ export const DialogSelectModelUnpaid: Component = () => { class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium" icon="dot-grid" onClick={() => { - dialog.replace(() => ) + dialog.show(() => ) }} > View all providers diff --git a/packages/desktop/src/components/dialog-select-model.tsx b/packages/desktop/src/components/dialog-select-model.tsx index f0b2e6db9..622ab15fb 100644 --- a/packages/desktop/src/components/dialog-select-model.tsx +++ b/packages/desktop/src/components/dialog-select-model.tsx @@ -28,7 +28,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => { class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} - onClick={() => dialog.replace(() => )} + onClick={() => dialog.show(() => )} > Connect provider @@ -57,7 +57,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => { local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true, }) - dialog.clear() + dialog.close() }} > {(i) => ( @@ -75,7 +75,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => { diff --git a/packages/desktop/src/components/dialog-select-provider.tsx b/packages/desktop/src/components/dialog-select-provider.tsx index 8da10b1d5..52fac7073 100644 --- a/packages/desktop/src/components/dialog-select-provider.tsx +++ b/packages/desktop/src/components/dialog-select-provider.tsx @@ -34,7 +34,7 @@ export const DialogSelectProvider: Component = () => { }} onSelect={(x) => { if (!x) return - dialog.replace(() => ) + dialog.show(() => ) }} > {(i) => ( diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index f2821c3c7..6e147242d 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -864,9 +864,7 @@ export const PromptInput: Component = (props) => { as="div" variant="ghost" onClick={() => - dialog.replace(() => - providers.paid().length > 0 ? : , - ) + dialog.show(() => (providers.paid().length > 0 ? : )) } > {local.model.current()?.name ?? "Select model"} diff --git a/packages/desktop/src/context/command.tsx b/packages/desktop/src/context/command.tsx index d4ef8e166..8fd76ee21 100644 --- a/packages/desktop/src/context/command.tsx +++ b/packages/desktop/src/context/command.tsx @@ -128,7 +128,7 @@ function DialogCommand(props: { options: CommandOption[] }) { groupBy={(x) => x.category ?? ""} onSelect={(option) => { if (option) { - dialog.clear() + dialog.close() option.onSelect?.("palette") } }} @@ -174,8 +174,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const suspended = () => suspendCount() > 0 const showPalette = () => { - if (dialog.stack.length === 0) { - dialog.replace(() => !x.disabled)} />) + if (!dialog.active) { + dialog.show(() => !x.disabled)} />) } } diff --git a/packages/desktop/src/context/notification.tsx b/packages/desktop/src/context/notification.tsx index 045361630..ee15bc34a 100644 --- a/packages/desktop/src/context/notification.tsx +++ b/packages/desktop/src/context/notification.tsx @@ -59,7 +59,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi time: Date.now(), viewed: false, } - switch (event?.type) { + switch (event.type) { case "session.idle": { const sessionID = event.properties.sessionID const [syncStore] = globalSync.child(directory) diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 2f0c6e050..27852967a 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -217,7 +217,7 @@ export default function Layout(props: ParentProps) { ]) function connectProvider() { - dialog.replace(() => ) + dialog.show(() => ) } function navigateToProject(directory: string | undefined) { diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 9167ef7b7..390872d36 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -176,7 +176,7 @@ export default function Page() { category: "File", keybind: "mod+p", slash: "open", - onSelect: () => dialog.replace(() => ), + onSelect: () => dialog.show(() => ), }, // { // id: "theme.toggle", @@ -245,7 +245,7 @@ export default function Page() { category: "Model", keybind: "mod+'", slash: "model", - onSelect: () => dialog.replace(() => ), + onSelect: () => dialog.show(() => ), }, { id: "agent.cycle", @@ -320,7 +320,7 @@ export default function Page() { const handleKeyDown = (event: KeyboardEvent) => { if ((document.activeElement as HTMLElement)?.dataset?.component === "terminal") return - if (dialog.stack.length > 0) return + if (dialog.active) return if (event.key === "PageUp" || event.key === "PageDown") { const scrollContainer = document.querySelector('[data-slot="session-turn-content"]') as HTMLElement @@ -613,7 +613,7 @@ export default function Page() { icon="plus-small" variant="ghost" iconSize="large" - onClick={() => dialog.replace(() => )} + onClick={() => dialog.show(() => )} />
diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index b15d96991..71fc63806 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -1,9 +1,7 @@ import { createContext, createEffect, - createMemo, createSignal, - For, getOwner, Owner, ParentProps, @@ -13,73 +11,58 @@ import { type JSX, } from "solid-js" import { Dialog as Kobalte } from "@kobalte/core/dialog" -import { iife } from "@opencode-ai/util/iife" type DialogElement = () => JSX.Element const Context = createContext>() function init() { - const [store, setStore] = createSignal< - { - id: string - element: DialogElement - onClose?: () => void - owner: Owner - }[] - >([]) + const [active, setActive] = createSignal< + | { + id: string + element: DialogElement + onClose?: () => void + owner: Owner + } + | undefined + >() const result = { - get stack() { - return store() - }, - pop() { - const current = store().at(-1) - if (!current) return - current?.onClose?.() - setStore((stack) => { - stack.pop() - return [...stack] - }) + get active() { + return active() }, - replace(element: DialogElement, owner: Owner, onClose?: () => void) { - for (const item of store()) { - item.onClose?.() - } - const id = Math.random().toString(36) - setStore([ - { - id, - element: () => - runWithOwner(owner, () => ( - - { - if (!open) { - onClose?.() - result.pop() - } - }} - > - - - {element()} - - - - )), - onClose, - owner, - }, - ]) + close() { + active()?.onClose?.() + setActive(undefined) }, - clear() { - for (const item of store()) { - item.onClose?.() - } - setStore([]) + show(element: DialogElement, owner: Owner, onClose?: () => void) { + active()?.onClose?.() + const id = Math.random().toString(36).slice(2) + setActive({ + id, + element: () => + runWithOwner(owner, () => ( + + { + if (!open) { + console.log("closing") + result.close() + } + }} + > + + + {element()} + + + + )), + onClose, + owner, + }) }, } @@ -89,14 +72,12 @@ function init() { export function DialogProvider(props: ParentProps) { const ctx = init() createEffect(() => { - console.log("store", ctx.stack.length) + console.log("active", ctx.active) }) return ( {props.children} -
- {(item) => <>{item.element()}} -
+
{ctx.active?.element?.()}
) } @@ -111,17 +92,14 @@ export function useDialog() { throw new Error("useDialog must be used within a DialogProvider") } return { - get stack() { - return ctx.stack - }, - replace(element: DialogElement, onClose?: () => void) { - ctx.replace(element, owner, onClose) + get active() { + return ctx.active }, - pop() { - ctx.pop() + show(element: DialogElement, onClose?: () => void) { + ctx.show(element, owner, onClose) }, - clear() { - ctx.clear() + close() { + ctx.close() }, } } -- cgit v1.2.3