summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components/prompt-input
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-06 09:37:49 -0600
committerGitHub <[email protected]>2026-02-06 09:37:49 -0600
commita4bc883595df9ea0f752079519081bc602408553 (patch)
tree583f21642f431899abe1dfb1f6bd9b2c01dc0206 /packages/app/src/components/prompt-input
parentc07077f96c0019b2e18e0e8e1e0383deda08b3e6 (diff)
downloadopencode-a4bc883595df9ea0f752079519081bc602408553.tar.gz
opencode-a4bc883595df9ea0f752079519081bc602408553.zip
chore: refactoring and tests (#12468)
Diffstat (limited to 'packages/app/src/components/prompt-input')
-rw-r--r--packages/app/src/components/prompt-input/attachments.ts132
-rw-r--r--packages/app/src/components/prompt-input/editor-dom.test.ts51
-rw-r--r--packages/app/src/components/prompt-input/editor-dom.ts135
-rw-r--r--packages/app/src/components/prompt-input/history.test.ts69
-rw-r--r--packages/app/src/components/prompt-input/history.ts160
-rw-r--r--packages/app/src/components/prompt-input/submit.ts587
6 files changed, 1134 insertions, 0 deletions
diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts
new file mode 100644
index 000000000..4ea2cfb90
--- /dev/null
+++ b/packages/app/src/components/prompt-input/attachments.ts
@@ -0,0 +1,132 @@
+import { onCleanup, onMount } from "solid-js"
+import { showToast } from "@opencode-ai/ui/toast"
+import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
+import { useLanguage } from "@/context/language"
+import { getCursorPosition } from "./editor-dom"
+
+export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
+export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
+
+type PromptAttachmentsInput = {
+ editor: () => HTMLDivElement | undefined
+ isFocused: () => boolean
+ isDialogActive: () => boolean
+ setDragging: (value: boolean) => void
+ addPart: (part: ContentPart) => void
+}
+
+export function createPromptAttachments(input: PromptAttachmentsInput) {
+ const prompt = usePrompt()
+ const language = useLanguage()
+
+ const addImageAttachment = async (file: File) => {
+ if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
+
+ const reader = new FileReader()
+ reader.onload = () => {
+ const editor = input.editor()
+ if (!editor) return
+ const dataUrl = reader.result as string
+ const attachment: ImageAttachmentPart = {
+ type: "image",
+ id: crypto.randomUUID(),
+ filename: file.name,
+ mime: file.type,
+ dataUrl,
+ }
+ const cursorPosition = prompt.cursor() ?? getCursorPosition(editor)
+ prompt.set([...prompt.current(), attachment], cursorPosition)
+ }
+ reader.readAsDataURL(file)
+ }
+
+ const removeImageAttachment = (id: string) => {
+ const current = prompt.current()
+ const next = current.filter((part) => part.type !== "image" || part.id !== id)
+ prompt.set(next, prompt.cursor())
+ }
+
+ const handlePaste = async (event: ClipboardEvent) => {
+ if (!input.isFocused()) return
+ const clipboardData = event.clipboardData
+ if (!clipboardData) return
+
+ event.preventDefault()
+ event.stopPropagation()
+
+ const items = Array.from(clipboardData.items)
+ const fileItems = items.filter((item) => item.kind === "file")
+ const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
+
+ if (imageItems.length > 0) {
+ for (const item of imageItems) {
+ const file = item.getAsFile()
+ if (file) await addImageAttachment(file)
+ }
+ return
+ }
+
+ if (fileItems.length > 0) {
+ showToast({
+ title: language.t("prompt.toast.pasteUnsupported.title"),
+ description: language.t("prompt.toast.pasteUnsupported.description"),
+ })
+ return
+ }
+
+ const plainText = clipboardData.getData("text/plain") ?? ""
+ if (!plainText) return
+ input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
+ }
+
+ const handleGlobalDragOver = (event: DragEvent) => {
+ if (input.isDialogActive()) return
+
+ event.preventDefault()
+ const hasFiles = event.dataTransfer?.types.includes("Files")
+ if (hasFiles) {
+ input.setDragging(true)
+ }
+ }
+
+ const handleGlobalDragLeave = (event: DragEvent) => {
+ if (input.isDialogActive()) return
+ if (!event.relatedTarget) {
+ input.setDragging(false)
+ }
+ }
+
+ const handleGlobalDrop = async (event: DragEvent) => {
+ if (input.isDialogActive()) return
+
+ event.preventDefault()
+ input.setDragging(false)
+
+ const dropped = event.dataTransfer?.files
+ if (!dropped) return
+
+ for (const file of Array.from(dropped)) {
+ if (ACCEPTED_FILE_TYPES.includes(file.type)) {
+ await addImageAttachment(file)
+ }
+ }
+ }
+
+ onMount(() => {
+ document.addEventListener("dragover", handleGlobalDragOver)
+ document.addEventListener("dragleave", handleGlobalDragLeave)
+ document.addEventListener("drop", handleGlobalDrop)
+ })
+
+ onCleanup(() => {
+ document.removeEventListener("dragover", handleGlobalDragOver)
+ document.removeEventListener("dragleave", handleGlobalDragLeave)
+ document.removeEventListener("drop", handleGlobalDrop)
+ })
+
+ return {
+ addImageAttachment,
+ removeImageAttachment,
+ handlePaste,
+ }
+}
diff --git a/packages/app/src/components/prompt-input/editor-dom.test.ts b/packages/app/src/components/prompt-input/editor-dom.test.ts
new file mode 100644
index 000000000..fce8b4b95
--- /dev/null
+++ b/packages/app/src/components/prompt-input/editor-dom.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, test } from "bun:test"
+import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
+
+describe("prompt-input editor dom", () => {
+ test("createTextFragment preserves newlines with br and zero-width placeholders", () => {
+ const fragment = createTextFragment("foo\n\nbar")
+ const container = document.createElement("div")
+ container.appendChild(fragment)
+
+ expect(container.childNodes.length).toBe(5)
+ expect(container.childNodes[0]?.textContent).toBe("foo")
+ expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
+ expect(container.childNodes[2]?.textContent).toBe("\u200B")
+ expect((container.childNodes[3] as HTMLElement).tagName).toBe("BR")
+ expect(container.childNodes[4]?.textContent).toBe("bar")
+ })
+
+ test("length helpers treat breaks as one char and ignore zero-width chars", () => {
+ const container = document.createElement("div")
+ container.appendChild(document.createTextNode("ab\u200B"))
+ container.appendChild(document.createElement("br"))
+ container.appendChild(document.createTextNode("cd"))
+
+ expect(getNodeLength(container.childNodes[0]!)).toBe(2)
+ expect(getNodeLength(container.childNodes[1]!)).toBe(1)
+ expect(getTextLength(container)).toBe(5)
+ })
+
+ test("setCursorPosition and getCursorPosition round-trip with pills and breaks", () => {
+ const container = document.createElement("div")
+ const pill = document.createElement("span")
+ pill.dataset.type = "file"
+ pill.textContent = "@file"
+ container.appendChild(document.createTextNode("ab"))
+ container.appendChild(pill)
+ container.appendChild(document.createElement("br"))
+ container.appendChild(document.createTextNode("cd"))
+ document.body.appendChild(container)
+
+ setCursorPosition(container, 2)
+ expect(getCursorPosition(container)).toBe(2)
+
+ setCursorPosition(container, 7)
+ expect(getCursorPosition(container)).toBe(7)
+
+ setCursorPosition(container, 8)
+ expect(getCursorPosition(container)).toBe(8)
+
+ container.remove()
+ })
+})
diff --git a/packages/app/src/components/prompt-input/editor-dom.ts b/packages/app/src/components/prompt-input/editor-dom.ts
new file mode 100644
index 000000000..3116ceb12
--- /dev/null
+++ b/packages/app/src/components/prompt-input/editor-dom.ts
@@ -0,0 +1,135 @@
+export function createTextFragment(content: string): DocumentFragment {
+ const fragment = document.createDocumentFragment()
+ const segments = content.split("\n")
+ segments.forEach((segment, index) => {
+ if (segment) {
+ fragment.appendChild(document.createTextNode(segment))
+ } else if (segments.length > 1) {
+ fragment.appendChild(document.createTextNode("\u200B"))
+ }
+ if (index < segments.length - 1) {
+ fragment.appendChild(document.createElement("br"))
+ }
+ })
+ return fragment
+}
+
+export function getNodeLength(node: Node): number {
+ if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
+ return (node.textContent ?? "").replace(/\u200B/g, "").length
+}
+
+export function getTextLength(node: Node): number {
+ if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
+ if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
+ let length = 0
+ for (const child of Array.from(node.childNodes)) {
+ length += getTextLength(child)
+ }
+ return length
+}
+
+export function getCursorPosition(parent: HTMLElement): number {
+ const selection = window.getSelection()
+ if (!selection || selection.rangeCount === 0) return 0
+ const range = selection.getRangeAt(0)
+ if (!parent.contains(range.startContainer)) return 0
+ const preCaretRange = range.cloneRange()
+ preCaretRange.selectNodeContents(parent)
+ preCaretRange.setEnd(range.startContainer, range.startOffset)
+ return getTextLength(preCaretRange.cloneContents())
+}
+
+export function setCursorPosition(parent: HTMLElement, position: number) {
+ let remaining = position
+ let node = parent.firstChild
+ while (node) {
+ const length = getNodeLength(node)
+ const isText = node.nodeType === Node.TEXT_NODE
+ const isPill =
+ node.nodeType === Node.ELEMENT_NODE &&
+ ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
+ const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
+
+ if (isText && remaining <= length) {
+ const range = document.createRange()
+ const selection = window.getSelection()
+ range.setStart(node, remaining)
+ range.collapse(true)
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ return
+ }
+
+ if ((isPill || isBreak) && remaining <= length) {
+ const range = document.createRange()
+ const selection = window.getSelection()
+ if (remaining === 0) {
+ range.setStartBefore(node)
+ }
+ if (remaining > 0 && isPill) {
+ range.setStartAfter(node)
+ }
+ if (remaining > 0 && isBreak) {
+ const next = node.nextSibling
+ if (next && next.nodeType === Node.TEXT_NODE) {
+ range.setStart(next, 0)
+ }
+ if (!next || next.nodeType !== Node.TEXT_NODE) {
+ range.setStartAfter(node)
+ }
+ }
+ range.collapse(true)
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ return
+ }
+
+ remaining -= length
+ node = node.nextSibling
+ }
+
+ const fallbackRange = document.createRange()
+ const fallbackSelection = window.getSelection()
+ const last = parent.lastChild
+ if (last && last.nodeType === Node.TEXT_NODE) {
+ const len = last.textContent ? last.textContent.length : 0
+ fallbackRange.setStart(last, len)
+ }
+ if (!last || last.nodeType !== Node.TEXT_NODE) {
+ fallbackRange.selectNodeContents(parent)
+ }
+ fallbackRange.collapse(false)
+ fallbackSelection?.removeAllRanges()
+ fallbackSelection?.addRange(fallbackRange)
+}
+
+export function setRangeEdge(parent: HTMLElement, range: Range, edge: "start" | "end", offset: number) {
+ let remaining = offset
+ const nodes = Array.from(parent.childNodes)
+
+ for (const node of nodes) {
+ const length = getNodeLength(node)
+ const isText = node.nodeType === Node.TEXT_NODE
+ const isPill =
+ node.nodeType === Node.ELEMENT_NODE &&
+ ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
+ const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
+
+ if (isText && remaining <= length) {
+ if (edge === "start") range.setStart(node, remaining)
+ if (edge === "end") range.setEnd(node, remaining)
+ return
+ }
+
+ if ((isPill || isBreak) && remaining <= length) {
+ if (edge === "start" && remaining === 0) range.setStartBefore(node)
+ if (edge === "start" && remaining > 0) range.setStartAfter(node)
+ if (edge === "end" && remaining === 0) range.setEndBefore(node)
+ if (edge === "end" && remaining > 0) range.setEndAfter(node)
+ return
+ }
+
+ remaining -= length
+ }
+}
diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts
new file mode 100644
index 000000000..54be9cb75
--- /dev/null
+++ b/packages/app/src/components/prompt-input/history.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, test } from "bun:test"
+import type { Prompt } from "@/context/prompt"
+import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history"
+
+const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
+
+const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
+
+describe("prompt-input history", () => {
+ test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => {
+ const first = prependHistoryEntry([], DEFAULT_PROMPT)
+ expect(first).toEqual([])
+
+ const withOne = prependHistoryEntry([], text("hello"))
+ expect(withOne).toHaveLength(1)
+
+ const deduped = prependHistoryEntry(withOne, text("hello"))
+ expect(deduped).toBe(withOne)
+ })
+
+ test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
+ const entries = [text("third"), text("second"), text("first")]
+ const up = navigatePromptHistory({
+ direction: "up",
+ entries,
+ historyIndex: -1,
+ currentPrompt: text("draft"),
+ savedPrompt: null,
+ })
+ expect(up.handled).toBe(true)
+ if (!up.handled) throw new Error("expected handled")
+ expect(up.historyIndex).toBe(0)
+ expect(up.cursor).toBe("start")
+
+ const down = navigatePromptHistory({
+ direction: "down",
+ entries,
+ historyIndex: up.historyIndex,
+ currentPrompt: text("ignored"),
+ savedPrompt: up.savedPrompt,
+ })
+ expect(down.handled).toBe(true)
+ if (!down.handled) throw new Error("expected handled")
+ expect(down.historyIndex).toBe(-1)
+ expect(down.prompt[0]?.type === "text" ? down.prompt[0].content : "").toBe("draft")
+ })
+
+ test("helpers clone prompt and count text content length", () => {
+ const original: Prompt = [
+ { type: "text", content: "one", start: 0, end: 3 },
+ {
+ type: "file",
+ path: "src/a.ts",
+ content: "@src/a.ts",
+ start: 3,
+ end: 12,
+ selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
+ },
+ { type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" },
+ ]
+ const copy = clonePromptParts(original)
+ expect(copy).not.toBe(original)
+ expect(promptLength(copy)).toBe(12)
+ if (copy[1]?.type !== "file") throw new Error("expected file")
+ copy[1].selection!.startLine = 9
+ if (original[1]?.type !== "file") throw new Error("expected file")
+ expect(original[1].selection?.startLine).toBe(1)
+ })
+})
diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts
new file mode 100644
index 000000000..63164f0ba
--- /dev/null
+++ b/packages/app/src/components/prompt-input/history.ts
@@ -0,0 +1,160 @@
+import type { Prompt } from "@/context/prompt"
+
+const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
+
+export const MAX_HISTORY = 100
+
+export function clonePromptParts(prompt: Prompt): Prompt {
+ return prompt.map((part) => {
+ if (part.type === "text") return { ...part }
+ if (part.type === "image") return { ...part }
+ if (part.type === "agent") return { ...part }
+ return {
+ ...part,
+ selection: part.selection ? { ...part.selection } : undefined,
+ }
+ })
+}
+
+export function promptLength(prompt: Prompt) {
+ return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
+}
+
+export function prependHistoryEntry(entries: Prompt[], prompt: Prompt, max = MAX_HISTORY) {
+ const text = prompt
+ .map((part) => ("content" in part ? part.content : ""))
+ .join("")
+ .trim()
+ const hasImages = prompt.some((part) => part.type === "image")
+ if (!text && !hasImages) return entries
+
+ const entry = clonePromptParts(prompt)
+ const last = entries[0]
+ if (last && isPromptEqual(last, entry)) return entries
+ return [entry, ...entries].slice(0, max)
+}
+
+function isPromptEqual(promptA: Prompt, promptB: Prompt) {
+ 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.type === "text" ? partB.content : "")) return false
+ if (partA.type === "file") {
+ if (partA.path !== (partB.type === "file" ? partB.path : "")) return false
+ const a = partA.selection
+ const b = partB.type === "file" ? partB.selection : undefined
+ const sameSelection =
+ (!a && !b) ||
+ (!!a &&
+ !!b &&
+ a.startLine === b.startLine &&
+ a.startChar === b.startChar &&
+ a.endLine === b.endLine &&
+ a.endChar === b.endChar)
+ if (!sameSelection) return false
+ }
+ if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false
+ if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
+ }
+ return true
+}
+
+type HistoryNavInput = {
+ direction: "up" | "down"
+ entries: Prompt[]
+ historyIndex: number
+ currentPrompt: Prompt
+ savedPrompt: Prompt | null
+}
+
+type HistoryNavResult =
+ | {
+ handled: false
+ historyIndex: number
+ savedPrompt: Prompt | null
+ }
+ | {
+ handled: true
+ historyIndex: number
+ savedPrompt: Prompt | null
+ prompt: Prompt
+ cursor: "start" | "end"
+ }
+
+export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult {
+ if (input.direction === "up") {
+ if (input.entries.length === 0) {
+ return {
+ handled: false,
+ historyIndex: input.historyIndex,
+ savedPrompt: input.savedPrompt,
+ }
+ }
+
+ if (input.historyIndex === -1) {
+ return {
+ handled: true,
+ historyIndex: 0,
+ savedPrompt: clonePromptParts(input.currentPrompt),
+ prompt: input.entries[0],
+ cursor: "start",
+ }
+ }
+
+ if (input.historyIndex < input.entries.length - 1) {
+ const next = input.historyIndex + 1
+ return {
+ handled: true,
+ historyIndex: next,
+ savedPrompt: input.savedPrompt,
+ prompt: input.entries[next],
+ cursor: "start",
+ }
+ }
+
+ return {
+ handled: false,
+ historyIndex: input.historyIndex,
+ savedPrompt: input.savedPrompt,
+ }
+ }
+
+ if (input.historyIndex > 0) {
+ const next = input.historyIndex - 1
+ return {
+ handled: true,
+ historyIndex: next,
+ savedPrompt: input.savedPrompt,
+ prompt: input.entries[next],
+ cursor: "end",
+ }
+ }
+
+ if (input.historyIndex === 0) {
+ if (input.savedPrompt) {
+ return {
+ handled: true,
+ historyIndex: -1,
+ savedPrompt: null,
+ prompt: input.savedPrompt,
+ cursor: "end",
+ }
+ }
+
+ return {
+ handled: true,
+ historyIndex: -1,
+ savedPrompt: null,
+ prompt: DEFAULT_PROMPT,
+ cursor: "end",
+ }
+ }
+
+ return {
+ handled: false,
+ historyIndex: input.historyIndex,
+ savedPrompt: input.savedPrompt,
+ }
+}
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts
new file mode 100644
index 000000000..1e5ebe4cb
--- /dev/null
+++ b/packages/app/src/components/prompt-input/submit.ts
@@ -0,0 +1,587 @@
+import { Accessor } from "solid-js"
+import { produce } from "solid-js/store"
+import { useNavigate, useParams } from "@solidjs/router"
+import { getFilename } from "@opencode-ai/util/path"
+import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client"
+import { Binary } from "@opencode-ai/util/binary"
+import { showToast } from "@opencode-ai/ui/toast"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { useLocal } from "@/context/local"
+import {
+ usePrompt,
+ type AgentPart,
+ type FileAttachmentPart,
+ type ImageAttachmentPart,
+ type Prompt,
+} from "@/context/prompt"
+import { useLayout } from "@/context/layout"
+import { useSDK } from "@/context/sdk"
+import { useSync } from "@/context/sync"
+import { useGlobalSync } from "@/context/global-sync"
+import { usePlatform } from "@/context/platform"
+import { useLanguage } from "@/context/language"
+import { Identifier } from "@/utils/id"
+import { Worktree as WorktreeState } from "@/utils/worktree"
+import type { FileSelection } from "@/context/file"
+import { setCursorPosition } from "./editor-dom"
+
+type PendingPrompt = {
+ abort: AbortController
+ cleanup: VoidFunction
+}
+
+const pending = new Map<string, PendingPrompt>()
+
+type PromptSubmitInput = {
+ info: Accessor<{ id: string } | undefined>
+ imageAttachments: Accessor<ImageAttachmentPart[]>
+ commentCount: Accessor<number>
+ mode: Accessor<"normal" | "shell">
+ working: Accessor<boolean>
+ editor: () => HTMLDivElement | undefined
+ queueScroll: () => void
+ promptLength: (prompt: Prompt) => number
+ addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
+ resetHistoryNavigation: () => void
+ setMode: (mode: "normal" | "shell") => void
+ setPopover: (popover: "at" | "slash" | null) => void
+ newSessionWorktree?: string
+ onNewSessionWorktreeReset?: () => void
+ onSubmit?: () => void
+}
+
+type CommentItem = {
+ path: string
+ selection?: FileSelection
+ comment?: string
+ commentID?: string
+ commentOrigin?: "review" | "file"
+ preview?: string
+}
+
+export function createPromptSubmit(input: PromptSubmitInput) {
+ const navigate = useNavigate()
+ const sdk = useSDK()
+ const sync = useSync()
+ const globalSync = useGlobalSync()
+ const platform = usePlatform()
+ const local = useLocal()
+ const prompt = usePrompt()
+ const layout = useLayout()
+ const language = useLanguage()
+ const params = useParams()
+
+ const errorMessage = (err: unknown) => {
+ if (err && typeof err === "object" && "data" in err) {
+ const data = (err as { data?: { message?: string } }).data
+ if (data?.message) return data.message
+ }
+ if (err instanceof Error) return err.message
+ return language.t("common.requestFailed")
+ }
+
+ const abort = async () => {
+ const sessionID = params.id
+ if (!sessionID) return Promise.resolve()
+ const queued = pending.get(sessionID)
+ if (queued) {
+ queued.abort.abort()
+ queued.cleanup()
+ pending.delete(sessionID)
+ return Promise.resolve()
+ }
+ return sdk.client.session
+ .abort({
+ sessionID,
+ })
+ .catch(() => {})
+ }
+
+ const restoreCommentItems = (items: CommentItem[]) => {
+ for (const item of items) {
+ prompt.context.add({
+ type: "file",
+ path: item.path,
+ selection: item.selection,
+ comment: item.comment,
+ commentID: item.commentID,
+ commentOrigin: item.commentOrigin,
+ preview: item.preview,
+ })
+ }
+ }
+
+ const removeCommentItems = (items: { key: string }[]) => {
+ for (const item of items) {
+ prompt.context.remove(item.key)
+ }
+ }
+
+ const handleSubmit = async (event: Event) => {
+ event.preventDefault()
+
+ const currentPrompt = prompt.current()
+ const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
+ const images = input.imageAttachments().slice()
+ const mode = input.mode()
+
+ if (text.trim().length === 0 && images.length === 0 && input.commentCount() === 0) {
+ if (input.working()) abort()
+ return
+ }
+
+ const currentModel = local.model.current()
+ const currentAgent = local.agent.current()
+ if (!currentModel || !currentAgent) {
+ showToast({
+ title: language.t("prompt.toast.modelAgentRequired.title"),
+ description: language.t("prompt.toast.modelAgentRequired.description"),
+ })
+ return
+ }
+
+ input.addToHistory(currentPrompt, mode)
+ input.resetHistoryNavigation()
+
+ const projectDirectory = sdk.directory
+ const isNewSession = !params.id
+ const worktreeSelection = input.newSessionWorktree ?? "main"
+
+ let sessionDirectory = projectDirectory
+ let client = sdk.client
+
+ if (isNewSession) {
+ if (worktreeSelection === "create") {
+ const createdWorktree = await client.worktree
+ .create({ directory: projectDirectory })
+ .then((x) => x.data)
+ .catch((err) => {
+ showToast({
+ title: language.t("prompt.toast.worktreeCreateFailed.title"),
+ description: errorMessage(err),
+ })
+ return undefined
+ })
+
+ if (!createdWorktree?.directory) {
+ showToast({
+ title: language.t("prompt.toast.worktreeCreateFailed.title"),
+ description: language.t("common.requestFailed"),
+ })
+ return
+ }
+ WorktreeState.pending(createdWorktree.directory)
+ sessionDirectory = createdWorktree.directory
+ }
+
+ if (worktreeSelection !== "main" && worktreeSelection !== "create") {
+ sessionDirectory = worktreeSelection
+ }
+
+ if (sessionDirectory !== projectDirectory) {
+ client = createOpencodeClient({
+ baseUrl: sdk.url,
+ fetch: platform.fetch,
+ directory: sessionDirectory,
+ throwOnError: true,
+ })
+ globalSync.child(sessionDirectory)
+ }
+
+ input.onNewSessionWorktreeReset?.()
+ }
+
+ let session = input.info()
+ if (!session && isNewSession) {
+ session = await client.session
+ .create()
+ .then((x) => x.data ?? undefined)
+ .catch((err) => {
+ showToast({
+ title: language.t("prompt.toast.sessionCreateFailed.title"),
+ description: errorMessage(err),
+ })
+ return undefined
+ })
+ if (session) {
+ layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
+ navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
+ }
+ }
+ if (!session) return
+
+ input.onSubmit?.()
+
+ const model = {
+ modelID: currentModel.id,
+ providerID: currentModel.provider.id,
+ }
+ const agent = currentAgent.name
+ const variant = local.model.variant.current()
+
+ const clearInput = () => {
+ prompt.reset()
+ input.setMode("normal")
+ input.setPopover(null)
+ }
+
+ const restoreInput = () => {
+ prompt.set(currentPrompt, input.promptLength(currentPrompt))
+ input.setMode(mode)
+ input.setPopover(null)
+ requestAnimationFrame(() => {
+ const editor = input.editor()
+ if (!editor) return
+ editor.focus()
+ setCursorPosition(editor, input.promptLength(currentPrompt))
+ input.queueScroll()
+ })
+ }
+
+ if (mode === "shell") {
+ clearInput()
+ client.session
+ .shell({
+ sessionID: session.id,
+ agent,
+ model,
+ command: text,
+ })
+ .catch((err) => {
+ showToast({
+ title: language.t("prompt.toast.shellSendFailed.title"),
+ description: errorMessage(err),
+ })
+ restoreInput()
+ })
+ return
+ }
+
+ if (text.startsWith("/")) {
+ const [cmdName, ...args] = text.split(" ")
+ const commandName = cmdName.slice(1)
+ const customCommand = sync.data.command.find((c) => c.name === commandName)
+ if (customCommand) {
+ clearInput()
+ client.session
+ .command({
+ sessionID: session.id,
+ command: commandName,
+ arguments: args.join(" "),
+ agent,
+ model: `${model.providerID}/${model.modelID}`,
+ variant,
+ parts: images.map((attachment) => ({
+ id: Identifier.ascending("part"),
+ type: "file" as const,
+ mime: attachment.mime,
+ url: attachment.dataUrl,
+ filename: attachment.filename,
+ })),
+ })
+ .catch((err) => {
+ showToast({
+ title: language.t("prompt.toast.commandSendFailed.title"),
+ description: errorMessage(err),
+ })
+ restoreInput()
+ })
+ return
+ }
+ }
+
+ const toAbsolutePath = (path: string) =>
+ path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/")
+
+ const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[]
+ const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
+
+ const fileAttachmentParts = fileAttachments.map((attachment) => {
+ const absolute = toAbsolutePath(attachment.path)
+ const query = attachment.selection
+ ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
+ : ""
+ return {
+ id: Identifier.ascending("part"),
+ type: "file" as const,
+ mime: "text/plain",
+ url: `file://${absolute}${query}`,
+ filename: getFilename(attachment.path),
+ source: {
+ type: "file" as const,
+ text: {
+ value: attachment.content,
+ start: attachment.start,
+ end: attachment.end,
+ },
+ path: absolute,
+ },
+ }
+ })
+
+ const agentAttachmentParts = agentAttachments.map((attachment) => ({
+ id: Identifier.ascending("part"),
+ type: "agent" as const,
+ name: attachment.name,
+ source: {
+ value: attachment.content,
+ start: attachment.start,
+ end: attachment.end,
+ },
+ }))
+
+ const usedUrls = new Set(fileAttachmentParts.map((part) => part.url))
+
+ const context = prompt.context.items().slice()
+ const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
+
+ const contextParts: Array<
+ | {
+ id: string
+ type: "text"
+ text: string
+ synthetic?: boolean
+ }
+ | {
+ id: string
+ type: "file"
+ mime: string
+ url: string
+ filename?: string
+ }
+ > = []
+
+ const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
+ const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
+ const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
+ const range =
+ start === undefined || end === undefined
+ ? "this file"
+ : start === end
+ ? `line ${start}`
+ : `lines ${start} through ${end}`
+
+ return `The user made the following comment regarding ${range} of ${path}: ${comment}`
+ }
+
+ const addContextFile = (item: { path: string; selection?: FileSelection; comment?: string }) => {
+ const absolute = toAbsolutePath(item.path)
+ const query = item.selection ? `?start=${item.selection.startLine}&end=${item.selection.endLine}` : ""
+ const url = `file://${absolute}${query}`
+
+ const comment = item.comment?.trim()
+ if (!comment && usedUrls.has(url)) return
+ usedUrls.add(url)
+
+ if (comment) {
+ contextParts.push({
+ id: Identifier.ascending("part"),
+ type: "text",
+ text: commentNote(item.path, item.selection, comment),
+ synthetic: true,
+ })
+ }
+
+ contextParts.push({
+ id: Identifier.ascending("part"),
+ type: "file",
+ mime: "text/plain",
+ url,
+ filename: getFilename(item.path),
+ })
+ }
+
+ for (const item of context) {
+ if (item.type !== "file") continue
+ addContextFile({ path: item.path, selection: item.selection, comment: item.comment })
+ }
+
+ const imageAttachmentParts = images.map((attachment) => ({
+ id: Identifier.ascending("part"),
+ type: "file" as const,
+ mime: attachment.mime,
+ url: attachment.dataUrl,
+ filename: attachment.filename,
+ }))
+
+ const messageID = Identifier.ascending("message")
+ const requestParts = [
+ {
+ id: Identifier.ascending("part"),
+ type: "text" as const,
+ text,
+ },
+ ...fileAttachmentParts,
+ ...contextParts,
+ ...agentAttachmentParts,
+ ...imageAttachmentParts,
+ ]
+
+ const optimisticParts = requestParts.map((part) => ({
+ ...part,
+ sessionID: session.id,
+ messageID,
+ })) as unknown as Part[]
+
+ const optimisticMessage: Message = {
+ id: messageID,
+ sessionID: session.id,
+ role: "user",
+ time: { created: Date.now() },
+ agent,
+ model,
+ }
+
+ const addOptimisticMessage = () => {
+ if (sessionDirectory === projectDirectory) {
+ sync.set(
+ produce((draft) => {
+ const messages = draft.message[session.id]
+ if (!messages) {
+ draft.message[session.id] = [optimisticMessage]
+ } else {
+ const result = Binary.search(messages, messageID, (m) => m.id)
+ messages.splice(result.index, 0, optimisticMessage)
+ }
+ draft.part[messageID] = optimisticParts
+ .filter((part) => !!part?.id)
+ .slice()
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+ }),
+ )
+ return
+ }
+
+ globalSync.child(sessionDirectory)[1](
+ produce((draft) => {
+ const messages = draft.message[session.id]
+ if (!messages) {
+ draft.message[session.id] = [optimisticMessage]
+ } else {
+ const result = Binary.search(messages, messageID, (m) => m.id)
+ messages.splice(result.index, 0, optimisticMessage)
+ }
+ draft.part[messageID] = optimisticParts
+ .filter((part) => !!part?.id)
+ .slice()
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+ }),
+ )
+ }
+
+ const removeOptimisticMessage = () => {
+ if (sessionDirectory === projectDirectory) {
+ sync.set(
+ produce((draft) => {
+ const messages = draft.message[session.id]
+ if (messages) {
+ const result = Binary.search(messages, messageID, (m) => m.id)
+ if (result.found) messages.splice(result.index, 1)
+ }
+ delete draft.part[messageID]
+ }),
+ )
+ return
+ }
+
+ globalSync.child(sessionDirectory)[1](
+ produce((draft) => {
+ const messages = draft.message[session.id]
+ if (messages) {
+ const result = Binary.search(messages, messageID, (m) => m.id)
+ if (result.found) messages.splice(result.index, 1)
+ }
+ delete draft.part[messageID]
+ }),
+ )
+ }
+
+ removeCommentItems(commentItems)
+ clearInput()
+ addOptimisticMessage()
+
+ const waitForWorktree = async () => {
+ const worktree = WorktreeState.get(sessionDirectory)
+ if (!worktree || worktree.status !== "pending") return true
+
+ if (sessionDirectory === projectDirectory) {
+ sync.set("session_status", session.id, { type: "busy" })
+ }
+
+ const controller = new AbortController()
+ const cleanup = () => {
+ if (sessionDirectory === projectDirectory) {
+ sync.set("session_status", session.id, { type: "idle" })
+ }
+ removeOptimisticMessage()
+ restoreCommentItems(commentItems)
+ restoreInput()
+ }
+
+ pending.set(session.id, { abort: controller, cleanup })
+
+ const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
+ if (controller.signal.aborted) {
+ resolve({ status: "failed", message: "aborted" })
+ return
+ }
+ controller.signal.addEventListener(
+ "abort",
+ () => {
+ resolve({ status: "failed", message: "aborted" })
+ },
+ { once: true },
+ )
+ })
+
+ const timeoutMs = 5 * 60 * 1000
+ const timer = { id: undefined as number | undefined }
+ const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
+ timer.id = window.setTimeout(() => {
+ resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
+ }, timeoutMs)
+ })
+
+ const result = await Promise.race([WorktreeState.wait(sessionDirectory), abortWait, timeout]).finally(() => {
+ if (timer.id === undefined) return
+ clearTimeout(timer.id)
+ })
+ pending.delete(session.id)
+ if (controller.signal.aborted) return false
+ if (result.status === "failed") throw new Error(result.message)
+ return true
+ }
+
+ const send = async () => {
+ const ok = await waitForWorktree()
+ if (!ok) return
+ await client.session.prompt({
+ sessionID: session.id,
+ agent,
+ model,
+ messageID,
+ parts: requestParts,
+ variant,
+ })
+ }
+
+ void send().catch((err) => {
+ pending.delete(session.id)
+ if (sessionDirectory === projectDirectory) {
+ sync.set("session_status", session.id, { type: "idle" })
+ }
+ showToast({
+ title: language.t("prompt.toast.promptSendFailed.title"),
+ description: errorMessage(err),
+ })
+ removeOptimisticMessage()
+ restoreCommentItems(commentItems)
+ restoreInput()
+ })
+ }
+
+ return {
+ abort,
+ handleSubmit,
+ }
+}