diff options
Diffstat (limited to 'packages/app/src/components/prompt-input')
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() |
