summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components/prompt-input
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app/src/components/prompt-input')
-rw-r--r--packages/app/src/components/prompt-input/build-request-parts.test.ts67
-rw-r--r--packages/app/src/components/prompt-input/build-request-parts.ts174
-rw-r--r--packages/app/src/components/prompt-input/context-items.tsx82
-rw-r--r--packages/app/src/components/prompt-input/drag-overlay.tsx20
-rw-r--r--packages/app/src/components/prompt-input/image-attachments.tsx51
-rw-r--r--packages/app/src/components/prompt-input/placeholder.test.ts35
-rw-r--r--packages/app/src/components/prompt-input/placeholder.ts13
-rw-r--r--packages/app/src/components/prompt-input/slash-popover.tsx144
-rw-r--r--packages/app/src/components/prompt-input/submit.ts222
9 files changed, 609 insertions, 199 deletions
diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts
new file mode 100644
index 000000000..b284c3884
--- /dev/null
+++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, test } from "bun:test"
+import type { Prompt } from "@/context/prompt"
+import { buildRequestParts } from "./build-request-parts"
+
+describe("buildRequestParts", () => {
+ test("builds typed request and optimistic parts without cast path", () => {
+ const prompt: Prompt = [
+ { type: "text", content: "hello", start: 0, end: 5 },
+ {
+ type: "file",
+ path: "src/foo.ts",
+ content: "@src/foo.ts",
+ start: 5,
+ end: 16,
+ selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
+ },
+ { type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
+ ]
+
+ const result = buildRequestParts({
+ prompt,
+ context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
+ images: [
+ { type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
+ ],
+ text: "hello @src/foo.ts @planner",
+ messageID: "msg_1",
+ sessionID: "ses_1",
+ sessionDirectory: "/repo",
+ })
+
+ expect(result.requestParts[0]?.type).toBe("text")
+ expect(result.requestParts.some((part) => part.type === "agent")).toBe(true)
+ expect(
+ result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
+ ).toBe(true)
+ expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
+
+ expect(result.optimisticParts).toHaveLength(result.requestParts.length)
+ expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
+ })
+
+ test("deduplicates context files when prompt already includes same path", () => {
+ const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
+
+ const result = buildRequestParts({
+ prompt,
+ context: [
+ { key: "ctx:dup", type: "file", path: "src/foo.ts" },
+ { key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
+ ],
+ images: [],
+ text: "@src/foo.ts",
+ messageID: "msg_2",
+ sessionID: "ses_2",
+ sessionDirectory: "/repo",
+ })
+
+ const fooFiles = result.requestParts.filter(
+ (part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"),
+ )
+ const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic)
+
+ expect(fooFiles).toHaveLength(2)
+ expect(synthetic).toHaveLength(1)
+ })
+})
diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts
new file mode 100644
index 000000000..4cf2f29ac
--- /dev/null
+++ b/packages/app/src/components/prompt-input/build-request-parts.ts
@@ -0,0 +1,174 @@
+import { getFilename } from "@opencode-ai/util/path"
+import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
+import type { FileSelection } from "@/context/file"
+import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
+import { Identifier } from "@/utils/id"
+
+type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
+
+type ContextFile = {
+ key: string
+ type: "file"
+ path: string
+ selection?: FileSelection
+ comment?: string
+ commentID?: string
+ commentOrigin?: "review" | "file"
+ preview?: string
+}
+
+type BuildRequestPartsInput = {
+ prompt: Prompt
+ context: ContextFile[]
+ images: ImageAttachmentPart[]
+ text: string
+ messageID: string
+ sessionID: string
+ sessionDirectory: string
+}
+
+const absolute = (directory: string, path: string) =>
+ path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/")
+
+const fileQuery = (selection: FileSelection | undefined) =>
+ selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
+
+const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
+const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
+
+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 toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
+ if (part.type === "text") {
+ return {
+ id: part.id,
+ type: "text",
+ text: part.text,
+ synthetic: part.synthetic,
+ ignored: part.ignored,
+ time: part.time,
+ metadata: part.metadata,
+ sessionID,
+ messageID,
+ }
+ }
+ if (part.type === "file") {
+ return {
+ id: part.id,
+ type: "file",
+ mime: part.mime,
+ filename: part.filename,
+ url: part.url,
+ source: part.source,
+ sessionID,
+ messageID,
+ }
+ }
+ return {
+ id: part.id,
+ type: "agent",
+ name: part.name,
+ source: part.source,
+ sessionID,
+ messageID,
+ }
+}
+
+export function buildRequestParts(input: BuildRequestPartsInput) {
+ const requestParts: PromptRequestPart[] = [
+ {
+ id: Identifier.ascending("part"),
+ type: "text",
+ text: input.text,
+ },
+ ]
+
+ const files = input.prompt.filter(isFileAttachment).map((attachment) => {
+ const path = absolute(input.sessionDirectory, attachment.path)
+ return {
+ id: Identifier.ascending("part"),
+ type: "file",
+ mime: "text/plain",
+ url: `file://${path}${fileQuery(attachment.selection)}`,
+ filename: getFilename(attachment.path),
+ source: {
+ type: "file",
+ text: {
+ value: attachment.content,
+ start: attachment.start,
+ end: attachment.end,
+ },
+ path,
+ },
+ } satisfies PromptRequestPart
+ })
+
+ const agents = input.prompt.filter(isAgentAttachment).map((attachment) => {
+ return {
+ id: Identifier.ascending("part"),
+ type: "agent",
+ name: attachment.name,
+ source: {
+ value: attachment.content,
+ start: attachment.start,
+ end: attachment.end,
+ },
+ } satisfies PromptRequestPart
+ })
+
+ const used = new Set(files.map((part) => part.url))
+ const context = input.context.flatMap((item) => {
+ const path = absolute(input.sessionDirectory, item.path)
+ const url = `file://${path}${fileQuery(item.selection)}`
+ const comment = item.comment?.trim()
+ if (!comment && used.has(url)) return []
+ used.add(url)
+
+ const filePart = {
+ id: Identifier.ascending("part"),
+ type: "file",
+ mime: "text/plain",
+ url,
+ filename: getFilename(item.path),
+ } satisfies PromptRequestPart
+
+ if (!comment) return [filePart]
+
+ return [
+ {
+ id: Identifier.ascending("part"),
+ type: "text",
+ text: commentNote(item.path, item.selection, comment),
+ synthetic: true,
+ } satisfies PromptRequestPart,
+ filePart,
+ ]
+ })
+
+ const images = input.images.map((attachment) => {
+ return {
+ id: Identifier.ascending("part"),
+ type: "file",
+ mime: attachment.mime,
+ url: attachment.dataUrl,
+ filename: attachment.filename,
+ } satisfies PromptRequestPart
+ })
+
+ requestParts.push(...files, ...context, ...agents, ...images)
+
+ return {
+ requestParts,
+ optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)),
+ }
+}
diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx
new file mode 100644
index 000000000..a843e109d
--- /dev/null
+++ b/packages/app/src/components/prompt-input/context-items.tsx
@@ -0,0 +1,82 @@
+import { Component, For, Show } from "solid-js"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
+import type { ContextItem } from "@/context/prompt"
+
+type PromptContextItem = ContextItem & { key: string }
+
+type ContextItemsProps = {
+ items: PromptContextItem[]
+ active: (item: PromptContextItem) => boolean
+ openComment: (item: PromptContextItem) => void
+ remove: (item: PromptContextItem) => void
+ t: (key: string) => string
+}
+
+export const PromptContextItems: Component<ContextItemsProps> = (props) => {
+ return (
+ <Show when={props.items.length > 0}>
+ <div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
+ <For each={props.items}>
+ {(item) => (
+ <Tooltip
+ value={
+ <span class="flex max-w-[300px]">
+ <span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
+ {getDirectory(item.path)}
+ </span>
+ <span class="shrink-0">{getFilename(item.path)}</span>
+ </span>
+ }
+ placement="top"
+ openDelay={2000}
+ >
+ <div
+ classList={{
+ "group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
+ "cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !props.active(item),
+ "cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
+ props.active(item),
+ "bg-background-stronger": !props.active(item),
+ }}
+ onClick={() => props.openComment(item)}
+ >
+ <div class="flex items-center gap-1.5">
+ <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
+ <div class="flex items-center text-11-regular min-w-0 font-medium">
+ <span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
+ <Show when={item.selection}>
+ {(sel) => (
+ <span class="text-text-weak whitespace-nowrap shrink-0">
+ {sel().startLine === sel().endLine
+ ? `:${sel().startLine}`
+ : `:${sel().startLine}-${sel().endLine}`}
+ </span>
+ )}
+ </Show>
+ </div>
+ <IconButton
+ type="button"
+ icon="close-small"
+ variant="ghost"
+ class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
+ onClick={(e) => {
+ e.stopPropagation()
+ props.remove(item)
+ }}
+ aria-label={props.t("prompt.context.removeFile")}
+ />
+ </div>
+ <Show when={item.comment}>
+ {(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
+ </Show>
+ </div>
+ </Tooltip>
+ )}
+ </For>
+ </div>
+ </Show>
+ )
+}
diff --git a/packages/app/src/components/prompt-input/drag-overlay.tsx b/packages/app/src/components/prompt-input/drag-overlay.tsx
new file mode 100644
index 000000000..f5a4d399e
--- /dev/null
+++ b/packages/app/src/components/prompt-input/drag-overlay.tsx
@@ -0,0 +1,20 @@
+import { Component, Show } from "solid-js"
+import { Icon } from "@opencode-ai/ui/icon"
+
+type PromptDragOverlayProps = {
+ dragging: boolean
+ label: string
+}
+
+export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
+ return (
+ <Show when={props.dragging}>
+ <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
+ <div class="flex flex-col items-center gap-2 text-text-weak">
+ <Icon name="photo" class="size-8" />
+ <span class="text-14-regular">{props.label}</span>
+ </div>
+ </div>
+ </Show>
+ )
+}
diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx
new file mode 100644
index 000000000..ba3addf0a
--- /dev/null
+++ b/packages/app/src/components/prompt-input/image-attachments.tsx
@@ -0,0 +1,51 @@
+import { Component, For, Show } from "solid-js"
+import { Icon } from "@opencode-ai/ui/icon"
+import type { ImageAttachmentPart } from "@/context/prompt"
+
+type PromptImageAttachmentsProps = {
+ attachments: ImageAttachmentPart[]
+ onOpen: (attachment: ImageAttachmentPart) => void
+ onRemove: (id: string) => void
+ removeLabel: string
+}
+
+export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
+ return (
+ <Show when={props.attachments.length > 0}>
+ <div class="flex flex-wrap gap-2 px-3 pt-3">
+ <For each={props.attachments}>
+ {(attachment) => (
+ <div class="relative group">
+ <Show
+ when={attachment.mime.startsWith("image/")}
+ fallback={
+ <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
+ <Icon name="folder" class="size-6 text-text-weak" />
+ </div>
+ }
+ >
+ <img
+ src={attachment.dataUrl}
+ alt={attachment.filename}
+ class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
+ onClick={() => props.onOpen(attachment)}
+ />
+ </Show>
+ <button
+ type="button"
+ onClick={() => props.onRemove(attachment.id)}
+ class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
+ aria-label={props.removeLabel}
+ >
+ <Icon name="close" class="size-3 text-text-weak" />
+ </button>
+ <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
+ <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
+ </div>
+ </div>
+ )}
+ </For>
+ </div>
+ </Show>
+ )
+}
diff --git a/packages/app/src/components/prompt-input/placeholder.test.ts b/packages/app/src/components/prompt-input/placeholder.test.ts
new file mode 100644
index 000000000..b633df829
--- /dev/null
+++ b/packages/app/src/components/prompt-input/placeholder.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, test } from "bun:test"
+import { promptPlaceholder } from "./placeholder"
+
+describe("promptPlaceholder", () => {
+ const t = (key: string, params?: Record<string, string>) => `${key}${params?.example ? `:${params.example}` : ""}`
+
+ test("returns shell placeholder in shell mode", () => {
+ const value = promptPlaceholder({
+ mode: "shell",
+ commentCount: 0,
+ example: "example",
+ t,
+ })
+ expect(value).toBe("prompt.placeholder.shell")
+ })
+
+ test("returns summarize placeholders for comment context", () => {
+ expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe(
+ "prompt.placeholder.summarizeComment",
+ )
+ expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe(
+ "prompt.placeholder.summarizeComments",
+ )
+ })
+
+ test("returns default placeholder with example", () => {
+ const value = promptPlaceholder({
+ mode: "normal",
+ commentCount: 0,
+ example: "translated-example",
+ t,
+ })
+ expect(value).toBe("prompt.placeholder.normal:translated-example")
+ })
+})
diff --git a/packages/app/src/components/prompt-input/placeholder.ts b/packages/app/src/components/prompt-input/placeholder.ts
new file mode 100644
index 000000000..07f6a43b5
--- /dev/null
+++ b/packages/app/src/components/prompt-input/placeholder.ts
@@ -0,0 +1,13 @@
+type PromptPlaceholderInput = {
+ mode: "normal" | "shell"
+ commentCount: number
+ example: string
+ t: (key: string, params?: Record<string, string>) => string
+}
+
+export function promptPlaceholder(input: PromptPlaceholderInput) {
+ if (input.mode === "shell") return input.t("prompt.placeholder.shell")
+ if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
+ if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
+ return input.t("prompt.placeholder.normal", { example: input.example })
+}
diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx
new file mode 100644
index 000000000..b97bb6752
--- /dev/null
+++ b/packages/app/src/components/prompt-input/slash-popover.tsx
@@ -0,0 +1,144 @@
+import { Component, For, Match, Show, Switch } from "solid-js"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { Icon } from "@opencode-ai/ui/icon"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+
+export type AtOption =
+ | { type: "agent"; name: string; display: string }
+ | { type: "file"; path: string; display: string; recent?: boolean }
+
+export interface SlashCommand {
+ id: string
+ trigger: string
+ title: string
+ description?: string
+ keybind?: string
+ type: "builtin" | "custom"
+ source?: "command" | "mcp" | "skill"
+}
+
+type PromptPopoverProps = {
+ popover: "at" | "slash" | null
+ setSlashPopoverRef: (el: HTMLDivElement) => void
+ atFlat: AtOption[]
+ atActive?: string
+ atKey: (item: AtOption) => string
+ setAtActive: (id: string) => void
+ onAtSelect: (item: AtOption) => void
+ slashFlat: SlashCommand[]
+ slashActive?: string
+ setSlashActive: (id: string) => void
+ onSlashSelect: (item: SlashCommand) => void
+ commandKeybind: (id: string) => string | undefined
+ t: (key: string) => string
+}
+
+export const PromptPopover: Component<PromptPopoverProps> = (props) => {
+ return (
+ <Show when={props.popover}>
+ <div
+ ref={(el) => {
+ if (props.popover === "slash") props.setSlashPopoverRef(el)
+ }}
+ class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
+ overflow-auto no-scrollbar flex flex-col p-2 rounded-md
+ border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
+ onMouseDown={(e) => e.preventDefault()}
+ >
+ <Switch>
+ <Match when={props.popover === "at"}>
+ <Show
+ when={props.atFlat.length > 0}
+ fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
+ >
+ <For each={props.atFlat.slice(0, 10)}>
+ {(item) => (
+ <button
+ classList={{
+ "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
+ "bg-surface-raised-base-hover": props.atActive === props.atKey(item),
+ }}
+ onClick={() => props.onAtSelect(item)}
+ onMouseEnter={() => props.setAtActive(props.atKey(item))}
+ >
+ <Show
+ when={item.type === "agent"}
+ fallback={
+ <>
+ <FileIcon
+ node={{ path: item.type === "file" ? item.path : "", type: "file" }}
+ class="shrink-0 size-4"
+ />
+ <div class="flex items-center text-14-regular min-w-0">
+ <span class="text-text-weak whitespace-nowrap truncate min-w-0">
+ {item.type === "file"
+ ? item.path.endsWith("/")
+ ? item.path
+ : getDirectory(item.path)
+ : ""}
+ </span>
+ <Show when={item.type === "file" && !item.path.endsWith("/")}>
+ <span class="text-text-strong whitespace-nowrap">
+ {item.type === "file" ? getFilename(item.path) : ""}
+ </span>
+ </Show>
+ </div>
+ </>
+ }
+ >
+ <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
+ <span class="text-14-regular text-text-strong whitespace-nowrap">
+ @{item.type === "agent" ? item.name : ""}
+ </span>
+ </Show>
+ </button>
+ )}
+ </For>
+ </Show>
+ </Match>
+ <Match when={props.popover === "slash"}>
+ <Show
+ when={props.slashFlat.length > 0}
+ fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyCommands")}</div>}
+ >
+ <For each={props.slashFlat}>
+ {(cmd) => (
+ <button
+ data-slash-id={cmd.id}
+ classList={{
+ "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
+ "bg-surface-raised-base-hover": props.slashActive === cmd.id,
+ }}
+ onClick={() => props.onSlashSelect(cmd)}
+ onMouseEnter={() => props.setSlashActive(cmd.id)}
+ >
+ <div class="flex items-center gap-2 min-w-0">
+ <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
+ <Show when={cmd.description}>
+ <span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
+ </Show>
+ </div>
+ <div class="flex items-center gap-2 shrink-0">
+ <Show when={cmd.type === "custom" && cmd.source !== "command"}>
+ <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
+ {cmd.source === "skill"
+ ? props.t("prompt.slash.badge.skill")
+ : cmd.source === "mcp"
+ ? props.t("prompt.slash.badge.mcp")
+ : props.t("prompt.slash.badge.custom")}
+ </span>
+ </Show>
+ <Show when={props.commandKeybind(cmd.id)}>
+ <span class="text-12-regular text-text-subtle">{props.commandKeybind(cmd.id)}</span>
+ </Show>
+ </div>
+ </button>
+ )}
+ </For>
+ </Show>
+ </Match>
+ </Switch>
+ </div>
+ </Show>
+ )
+}
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts
index 1e5ebe4cb..5ed5eedad 100644
--- a/packages/app/src/components/prompt-input/submit.ts
+++ b/packages/app/src/components/prompt-input/submit.ts
@@ -1,19 +1,10 @@
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 { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
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 { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
@@ -24,6 +15,7 @@ import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree"
import type { FileSelection } from "@/context/file"
import { setCursorPosition } from "./editor-dom"
+import { buildRequestParts } from "./build-request-parts"
type PendingPrompt = {
abort: AbortController
@@ -290,138 +282,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
}
- 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,
+ const { requestParts, optimisticParts } = buildRequestParts({
+ prompt: currentPrompt,
+ context,
+ images,
+ text,
sessionID: session.id,
messageID,
- })) as unknown as Part[]
+ sessionDirectory,
+ })
const optimisticMessage: Message = {
id: messageID,
@@ -432,69 +305,20 @@ export function createPromptSubmit(input: PromptSubmitInput) {
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
- }
+ const addOptimisticMessage = () =>
+ sync.session.optimistic.add({
+ directory: sessionDirectory,
+ sessionID: session.id,
+ message: optimisticMessage,
+ parts: optimisticParts,
+ })
- 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]
- }),
- )
- }
+ const removeOptimisticMessage = () =>
+ sync.session.optimistic.remove({
+ directory: sessionDirectory,
+ sessionID: session.id,
+ messageID,
+ })
removeCommentItems(commentItems)
clearInput()