diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index 5fa7a48b864..b8190396ad0 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -485,6 +485,7 @@ export function stepStarted(message: SessionMessageAssistant) { assistantMessageID: message.id, agent: message.agent, model: message.model, + started: message.time.created, }) } diff --git a/packages/app/e2e/regression/session-queue.spec.ts b/packages/app/e2e/regression/session-queue.spec.ts index b70e1f76ea6..fe408060a4c 100644 --- a/packages/app/e2e/regression/session-queue.spec.ts +++ b/packages/app/e2e/regression/session-queue.spec.ts @@ -284,7 +284,7 @@ for (const delivery of ["steer", "queue"] as const) { await expect(thinking).toHaveCount(0) // The next assistant step still belongs to U1: U2 has been admitted, not delivered. - mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model }) + mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model, started: Date.now() }) for (const tool of [ { id: "tool_queue_read", name: "read", input: { path: "src/queue.ts" } }, { id: "tool_queue_grep", name: "grep", input: { pattern: "retry", path: "src" } }, @@ -341,7 +341,7 @@ for (const delivery of ["steer", "queue"] as const) { ) const later = { sessionID, assistantMessageID: "msg_queue_follow_up_assistant" } - mock.emit("session.step.started", { ...later, agent: "build", model }) + mock.emit("session.step.started", { ...later, agent: "build", model, started: Date.now() }) mock.emit("session.text.started", { ...later, ordinal: 0 }) mock.emit("session.text.ended", { ...later, ordinal: 0, text: "A3: Now checking the retry path for U2." }) const response = transcript diff --git a/packages/app/src/composer/attachments/attachments.css b/packages/app/src/composer/attachments/attachments.css index a70f276e521..be74198b07d 100644 --- a/packages/app/src/composer/attachments/attachments.css +++ b/packages/app/src/composer/attachments/attachments.css @@ -16,6 +16,86 @@ } } +[data-component="upload-row"] { + display: flex; + flex-direction: column; + gap: 4px; + + & + & { + margin-top: 8px; + } + + [data-slot="upload-row-label"] { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + font-size: 13px; + line-height: var(--line-height-base); + letter-spacing: -0.04px; + } + + [data-slot="upload-row-name"] { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--v2-text-text-base); + font-weight: 530; + } + + [data-slot="upload-row-percent"] { + flex-shrink: 0; + color: var(--v2-text-text-muted); + font-variant-numeric: tabular-nums; + } + + [data-slot="upload-row-track"] { + display: flex; + align-items: center; + gap: 8px; + } + + [data-component="upload-progress"] { + flex: 1; + height: 4px; + overflow: hidden; + border-radius: 999px; + background: var(--v2-background-bg-layer-02); + } + + [data-slot="upload-progress-bar"] { + height: 100%; + border-radius: inherit; + background: var(--v2-icon-icon-base); + transition: width 160ms ease-out; + } + + [data-slot="upload-row-cancel"] { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--v2-icon-icon-muted); + cursor: pointer; + + &:hover { + background: var(--v2-overlay-simple-overlay-hover); + color: var(--v2-icon-icon-base); + } + + svg { + width: 12px; + height: 12px; + } + } +} + [data-component="composer-attachments"] { timeline-scope: --composer-attachments-scroll; diff --git a/packages/app/src/composer/attachments/attachments.ts b/packages/app/src/composer/attachments/attachments.ts index 9650c4ebfba..8350ea46c49 100644 --- a/packages/app/src/composer/attachments/attachments.ts +++ b/packages/app/src/composer/attachments/attachments.ts @@ -1,8 +1,12 @@ import { onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" import { createBlobReference } from "@/runtime/persistence/drafts" import { uuid } from "@/runtime/persistence/uuid" -import type { ComposerAttachment, ComposerPrompt } from "../types" +import type { ComposerPrompt } from "../types" +import type { ImageAttachmentPart, PathAttachmentPart } from "../state" +import type { AttachmentDestination } from "./destination" +import { uploads } from "./uploads" type PromptTarget = { current: () => ComposerPrompt @@ -16,9 +20,11 @@ export type ComposerAttachmentConfig = { onFile: (file: File) => Promise, ) => Promise directory: () => string + destination: () => AttachmentDestination isDialogActive: () => boolean duplicate: () => void onError: (error: unknown) => void + onUploadError: (error: unknown) => void readClipboardImage?: () => Promise getPathForFile?: (file: File) => string onDragCancel?: (callback: () => void) => () => void @@ -43,9 +49,23 @@ export function createComposerAttachments( if (!editor) return return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) } } + // Uploads this composer started; they finish (or fail) even if the composer unmounts. + const [pending, setPending] = createStore<{ ids: string[] }>({ ids: [] }) + + // A file the model reads natively travels inline with the prompt, so its bytes live in the draft + // store. Anything else reaches the model as a path on the server and never enters the store: + // hashing and copying a large archive through it is what used to freeze the window. const add = async (file: File, target = capture(), clipboard = false) => { if (!target) return false const mime = await attachmentMime(file) + const destination = input.destination() + if (native(mime, destination.input)) return addInline(file, mime, target, clipboard) + const sourcePath = input.getPathForFile?.(file) || undefined + if (destination.local && sourcePath) return addPath(target, { filename: file.name, mime, path: sourcePath }) + void stage(file, mime, target, destination) + return true + } + const addInline = async (file: File, mime: string, target: NonNullable>, clipboard: boolean) => { const blob = input.store ? await input.store(file) : await createBlobReference(file) const sourcePath = input.getPathForFile?.(file) || undefined // Native clipboard images arrive with a fresh timestamped filename on every paste, so identical @@ -64,17 +84,40 @@ export function createComposerAttachments( input.duplicate() return true } - const attachment: ComposerAttachment = { - type: "image", - id: uuid(), - filename: file.name, - sourcePath, - mime, - blob, - } + const attachment: ImageAttachmentPart = { type: "image", id: uuid(), filename: file.name, sourcePath, mime, blob } target.prompt.set([...target.prompt.current(), attachment], target.cursor) return true } + const addPath = ( + target: NonNullable>, + attachment: Pick, + ) => { + if (target.prompt.current().some((part) => part.type === "path" && part.path === attachment.path)) { + input.duplicate() + return true + } + target.prompt.set([...target.prompt.current(), { type: "path", id: uuid(), ...attachment }], target.prompt.cursor()) + return true + } + const stage = async ( + file: File, + mime: string, + target: NonNullable>, + destination: AttachmentDestination, + ) => { + const id = uuid() + setPending("ids", (ids) => [...ids, id]) + const path = await uploads + .track({ id, filename: file.name, mime, size: file.size }, (report, signal) => + destination.upload(file, report, signal), + ) + .catch((error: unknown) => { + input.onUploadError(error) + return undefined + }) + .finally(() => setPending("ids", (ids) => ids.filter((item) => item !== id))) + if (path) addPath(target, { filename: file.name, mime, path }) + } const addAttachments = async (files: File[], target = capture()) => { return files.reduce(async (result, file) => { const previous = await result @@ -153,6 +196,11 @@ export function createComposerAttachments( addAttachments, handlePaste, handleDrop, + /** Uploads still in flight for this composer; sending waits for them. */ + pending: () => uploads.items().filter((item) => pending.ids.includes(item.id)), + cancel(id: string) { + uploads.items().find((item) => item.id === id)?.cancel() + }, pick(fallback: () => void) { if (!input.picker) { fallback() @@ -165,6 +213,14 @@ export function createComposerAttachments( const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]) +// Mirrors the attachment kinds the server forwards to the model as message content. +function native(mime: string, input: AttachmentDestination["input"]) { + if (mime === "text/plain") return true + if (imageMimes.has(mime)) return input.image + if (mime === "application/pdf") return input.pdf + return false +} + const imageExtensions = new Map([ ["gif", "image/gif"], ["jpeg", "image/jpeg"], diff --git a/packages/app/src/composer/attachments/deliver.ts b/packages/app/src/composer/attachments/deliver.ts deleted file mode 100644 index a1cfca16c83..00000000000 --- a/packages/app/src/composer/attachments/deliver.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { Accessor } from "solid-js" -import { blobBytes, blobDataUrl } from "@/runtime/persistence/drafts" -import { useServer } from "@/runtime/server/current" -import { useServerSDK } from "@/runtime/server/client" -import { useWorkspaceLocation } from "@/workspaces/location" -import type { ComposerControls } from "../adapter" -import type { ImageAttachmentPart } from "../state" - -// Where a prompt is headed: the model that reads it and the server that runs its tools. -export type AttachmentDestination = { - /** Input modalities the selected model reads natively. */ - input: { image: boolean; pdf: boolean } - /** The server shares the client's filesystem, so an attachment's source path resolves as-is. */ - local: boolean - /** Copies a file into the server's temporary directory and returns its absolute path there. */ - upload: (file: { name: string; data: Uint8Array }) => Promise -} - -export type DeliveredAttachment = - | { type: "inline"; attachment: ImageAttachmentPart; dataUrl: string } - | { type: "path"; attachment: ImageAttachmentPart; path: string } - -// An attachment travels inline when the model reads its bytes natively. Anything else reaches -// the model as a path on the server, which its tools can open, instead of being rejected. -export function deliverAttachments(attachments: ImageAttachmentPart[], destination: AttachmentDestination) { - return Promise.all(attachments.map((attachment) => deliver(attachment, destination))) -} - -async function deliver( - attachment: ImageAttachmentPart, - destination: AttachmentDestination, -): Promise { - if (native(attachment.mime, destination.input)) { - return { type: "inline", attachment, dataUrl: await blobDataUrl(attachment.blob, attachment.mime) } - } - if (destination.local && attachment.sourcePath) return { type: "path", attachment, path: attachment.sourcePath } - const path = await destination.upload({ name: attachment.filename, data: await blobBytes(attachment.blob) }) - return { type: "path", attachment, path } -} - -const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]) - -// Mirrors the attachment kinds the server forwards to the model as message content. -function native(mime: string, input: AttachmentDestination["input"]) { - if (mime === "text/plain") return true - if (imageMimes.has(mime)) return input.image - if (mime === "application/pdf") return input.pdf - return false -} - -export function useAttachmentDestination(controls: Accessor) { - const server = useServer() - const sdk = useServerSDK() - const location = useWorkspaceLocation() - return (): AttachmentDestination => ({ - input: controls().model.selection.current()?.capabilities.input ?? { image: false, pdf: false }, - local: server.isLocal, - upload: async (file) => { - const info = await sdk.api.server.info() - // One directory per upload keeps the original filename without collisions; the server - // normalizes the separators and returns the resolved path. - const written = await sdk.api.file.write({ - location: { directory: location().directory }, - path: `${info.paths.tmp}/uploads/${crypto.randomUUID()}/${file.name}`, - payload: file.data, - }) - return written.data.path - }, - }) -} diff --git a/packages/app/src/composer/attachments/destination.ts b/packages/app/src/composer/attachments/destination.ts new file mode 100644 index 00000000000..45aa4ed987c --- /dev/null +++ b/packages/app/src/composer/attachments/destination.ts @@ -0,0 +1,57 @@ +import type { Accessor } from "solid-js" +import { useServer } from "@/runtime/server/current" +import { useServerSDK } from "@/runtime/server/client" +import { authTokenFromCredentials } from "@/runtime/server/api" +import { useWorkspaceLocation } from "@/workspaces/location" +import type { ComposerControls } from "../adapter" + +// Where a prompt is headed: the model that reads it and the server that runs its tools. +export type AttachmentDestination = { + /** Input modalities the selected model reads natively. */ + input: { image: boolean; pdf: boolean } + /** The server shares the client's filesystem, so an attachment's source path resolves as-is. */ + local: boolean + /** Streams a file into the server's temporary directory and returns its absolute path there. */ + upload: (file: File, report: (loaded: number) => void, signal: AbortSignal) => Promise +} + +export function useAttachmentDestination(controls: Accessor) { + const server = useServer() + const sdk = useServerSDK() + const location = useWorkspaceLocation() + return (): AttachmentDestination => ({ + input: controls().model.selection.current()?.capabilities.input ?? { image: false, pdf: false }, + local: server.isLocal, + upload: async (file, report, signal) => { + const info = await sdk.api.server.info({ signal }) + // One directory per upload keeps the original filename without collisions; the server + // normalizes the separators and returns the resolved path. + const url = new URL("/api/experimental/fs/write", server.conn.http.url) + url.searchParams.set("location[directory]", location().directory) + url.searchParams.set("path", `${info.paths.tmp}/uploads/${crypto.randomUUID()}/${file.name}`) + return write(url, file, server.conn.http.password, report, signal) + }, + }) +} + +// fetch cannot report upload progress and Chromium only streams request bodies over HTTP/2, so +// the one request that needs both goes through XMLHttpRequest. The browser streams the File +// from disk; nothing is buffered in the renderer. +function write(url: URL, file: File, password: string | undefined, report: (loaded: number) => void, signal: AbortSignal) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open("POST", url) + xhr.responseType = "json" + xhr.setRequestHeader("content-type", "application/octet-stream") + if (password) xhr.setRequestHeader("authorization", `Basic ${authTokenFromCredentials({ password })}`) + xhr.upload.addEventListener("progress", (event) => report(event.loaded)) + xhr.addEventListener("load", () => { + if (xhr.status !== 200) return reject(new Error(`Upload failed with status ${xhr.status}`)) + resolve((xhr.response as { data: { path: string } }).data.path) + }) + xhr.addEventListener("error", () => reject(new Error("Upload failed"))) + xhr.addEventListener("abort", () => reject(new DOMException("Upload aborted", "AbortError"))) + signal.addEventListener("abort", () => xhr.abort(), { once: true }) + xhr.send(file) + }) +} diff --git a/packages/app/src/composer/attachments/uploads.tsx b/packages/app/src/composer/attachments/uploads.tsx new file mode 100644 index 00000000000..8682d000465 --- /dev/null +++ b/packages/app/src/composer/attachments/uploads.tsx @@ -0,0 +1,116 @@ +import { createEffect, createRoot, For, on, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { Icon } from "@opencode/ui/icon" +import { Toast, toaster } from "@opencode/ui/toast" +import { useLanguage } from "@/runtime/i18n/language" + +export type Upload = { + id: string + filename: string + mime: string + size: number + loaded: number + cancel: () => void +} + +// Uploads outlive the composer that started them, so one process-wide list feeds every chip +// and the single progress toast. +const [state, setState] = createStore<{ items: Upload[] }>({ items: [] }) + +export const uploads = { + items: () => state.items, + /** Runs `work` while the upload is listed. Resolves to undefined when the user cancels it. */ + async track( + input: Pick, + work: (report: (loaded: number) => void, signal: AbortSignal) => Promise, + ): Promise { + const controller = new AbortController() + setState("items", (items) => [...items, { ...input, loaded: 0, cancel: () => controller.abort() }]) + try { + return await work((loaded) => setState("items", (item) => item.id === input.id, "loaded", loaded), controller.signal) + } catch (error) { + if (controller.signal.aborted) return undefined + throw error + } finally { + setState("items", (items) => items.filter((item) => item.id !== input.id)) + } + }, +} + +// Sonner builds toast content outside the app's Solid tree: no context and no owner. This host +// lives inside the providers, lends the toast its language instance, and gives the content a +// root of its own so progress stays reactive. +export function UploadToastHost() { + const language = useLanguage() + let active: { id: number; dispose: () => void } | undefined + const dismiss = () => { + if (!active) return + toaster.dismiss(active.id) + active.dispose() + active = undefined + } + createEffect( + on( + () => state.items.length > 0, + (uploading) => { + if (!uploading) return dismiss() + if (active) return + const id = toaster.show( + (props) => + createRoot((dispose) => { + active = { id: props.toastId, dispose } + return + }), + { persistent: true, resize: () => state.items.length }, + ) + active ??= { id, dispose: () => {} } + }, + ), + ) + onCleanup(dismiss) + return null +} + +function UploadToast(props: { toastId: number; language: ReturnType }) { + const percent = (item: Upload) => (item.size === 0 ? 100 : Math.floor((item.loaded / item.size) * 100)) + return ( + + + + {(item) => ( +
+
+ + {item.filename} + + + {props.language.t("prompt.toast.uploading.percent", { percent: percent(item) })} + +
+
+
+
+
+ +
+
+ )} + + + + ) +} diff --git a/packages/app/src/composer/composer.stories.tsx b/packages/app/src/composer/composer.stories.tsx index c3b72eec1dd..be246d89647 100644 --- a/packages/app/src/composer/composer.stories.tsx +++ b/packages/app/src/composer/composer.stories.tsx @@ -167,7 +167,7 @@ function ComposerStory(props: { ? buildPromptRequest({ prompt: draft.prompt, context: draft.context.items, - attachments: [], + images: [], text: value, sessionDirectory: "C:/repo", }) diff --git a/packages/app/src/composer/editor/actions.ts b/packages/app/src/composer/editor/actions.ts index d0ffe2f3415..9d2b4c74278 100644 --- a/packages/app/src/composer/editor/actions.ts +++ b/packages/app/src/composer/editor/actions.ts @@ -7,7 +7,7 @@ import type { ComposerPersistedState, ComposerPrompt, } from "../types" -import { promptLength } from "../prompt-parts" +import { isAttachment, promptLength } from "../prompt-parts" export type ComposerStateStore = [ Store | Accessor>, @@ -48,7 +48,7 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) { setStore()((state) => ({ prompt: [ { type: "text", content, start: 0, end: content.length }, - ...state.prompt.filter((part) => part.type === "image"), + ...state.prompt.filter(isAttachment), ], cursor: content.length, retry: undefined, @@ -83,7 +83,7 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) { clearRetry() }, removeAttachment(id: string) { - setStore()("prompt", (parts) => parts.filter((part) => part.type !== "image" || part.id !== id)) + setStore()("prompt", (parts) => parts.filter((part) => !isAttachment(part) || part.id !== id)) clearRetry() }, } @@ -93,7 +93,7 @@ function insertText(prompt: ComposerPrompt, cursor: number, content: string): Co let position = 0 let inserted = false const parts = prompt.flatMap((part) => { - if (part.type === "image") return [part] + if (isAttachment(part)) return [part] const start = position position += part.content.length if (inserted) return [part] @@ -121,7 +121,7 @@ function insertMention( } let position = 0 const parts = prompt.flatMap((part) => { - if (part.type === "image") return [part] + if (isAttachment(part)) return [part] const partStart = position position += part.content.length if (part.type !== "text" || start < partStart || end > position) return [part] @@ -139,7 +139,7 @@ function insertMention( function withOffsets(prompt: ComposerPrompt): ComposerPrompt { let offset = 0 return prompt.map((part) => { - if (part.type === "image") return part + if (isAttachment(part)) return part const next = { ...part, start: offset, end: offset + part.content.length } offset = next.end return next diff --git a/packages/app/src/composer/editor/editor.tsx b/packages/app/src/composer/editor/editor.tsx index cf0b0ea258e..51ad419a36a 100644 --- a/packages/app/src/composer/editor/editor.tsx +++ b/packages/app/src/composer/editor/editor.tsx @@ -12,6 +12,8 @@ import { Menu } from "@opencode/ui/menu" import { Tooltip } from "@opencode/ui/tooltip" import { ScrollView } from "@opencode/ui/scroll-view" import { AttachmentCard } from "@opencode/session-ui/attachment-card" +import { ProgressCircle } from "@opencode/ui/progress-circle" +import type { Upload } from "../attachments/uploads" import { CommentCard } from "@opencode/session-ui/comment-card" import { typeLabel } from "@opencode/session-ui/message-file" import { Skill } from "@opencode/schema/skill" @@ -24,6 +26,7 @@ import type { ComposerSuggestion, } from "../types" import type { ComposerEditorModel, ComposerSelectControl } from "./interaction" +import { isAttachment } from "../prompt-parts" import "../attachments/attachments.css" import "./editor.css" @@ -148,11 +151,13 @@ export function ComposerEditor(props: ComposerEditorProps) { props.controller.removeAttachment(attachment.id)} + onUploadCancel={(upload) => props.controller.cancelUpload(upload.id)} onCommentClick={(comment) => props.controller.toggleContext(comment.key)} onCommentRemove={(comment) => props.controller.removeContext(comment.key)} /> @@ -191,9 +196,9 @@ export function ComposerEditor(props: ComposerEditorProps) { onInput={(event) => { const cursor = composerCursor(event.currentTarget) const prompt = parseComposerEditor(event.currentTarget) - const images = props.controller.parts().filter((part) => part.type === "image") + const attachments = props.controller.parts().filter(isAttachment) localInput = true - props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor) + props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...attachments], cursor) }} onKeyDown={(event) => { if (!view.draftOnly && props.controller.onKeyDown(event)) return @@ -348,7 +353,7 @@ function renderComposerEditor(editor: HTMLDivElement, prompt: ComposerPrompt) { const active = document.activeElement === editor editor.replaceChildren( ...prompt.flatMap((part) => { - if (part.type === "image") return [] + if (isAttachment(part)) return [] if (part.type === "text") return [document.createTextNode(part.content)] const mention = document.createElement("span") mentionParts.set(mention, part) @@ -475,17 +480,22 @@ function composerCursor(editor: HTMLDivElement) { export function ComposerAttachments(props: { attachments: ComposerAttachment[] + uploads?: Upload[] comments?: ComposerComment[] activeCommentID?: string removeLabel: string onAttachmentClick?: (attachment: ComposerAttachment) => void onAttachmentRemove: (attachment: ComposerAttachment) => void + onUploadCancel?: (upload: Upload) => void onCommentClick?: (comment: ComposerComment) => void onCommentRemove?: (comment: ComposerComment) => void }) { const i18n = useI18n() + const percent = (upload: Upload) => (upload.size === 0 ? 100 : Math.floor((upload.loaded / upload.size) * 100)) return ( - 0 || (props.comments?.length ?? 0) > 0}> + 0 || (props.uploads?.length ?? 0) > 0 || (props.comments?.length ?? 0) > 0} + >
{(attachment) => (
- + {typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))} } > + {(image) => ( + <> {attachment.filename} props.onAttachmentClick?.(attachment)} />
+ + )} +
+ )} +
part.type === "image"), + draft.state.prompt.filter(isAttachment), 0, ) } @@ -315,7 +316,13 @@ export function createComposerEditor(input: { return draft.state.context.items.filter((item) => !!item.comment?.trim()) }, attachments(): ComposerAttachment[] { - return draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image") + return draft.state.prompt.filter(isAttachment) + }, + uploads(): Upload[] { + return attachments?.pending() ?? [] + }, + cancelUpload(id: string) { + attachments?.cancel(id) }, toggleContext(id: string) { dispatch({ type: "context.active", id }) @@ -336,11 +343,12 @@ export function createComposerEditor(input: { canSubmit() { if (input.view.submit.available?.() === false) return false if (input.view.draftOnly) return false + if (attachments?.pending().length) return false const persisted = draft.state if (state.mode === "shell") { return persisted.prompt.some((part) => "content" in part && !!part.content.trim()) } - if (persisted.prompt.some((part) => part.type === "image")) return true + if (persisted.prompt.some(isAttachment)) return true if (persisted.context.items.some((item) => !!item.comment?.trim())) return true return persisted.prompt.some((part) => "content" in part && !!part.content.trim()) }, @@ -369,6 +377,7 @@ export function createComposerEditor(input: { submit(options?: { alternate?: boolean }) { if (input.view.submit.available?.() === false) return if (input.view.draftOnly) return + if (attachments?.pending().length) return input.view.submit.onSubmit(options) dispatch({ type: "popover.close" }) }, diff --git a/packages/app/src/composer/history/entry.ts b/packages/app/src/composer/history/entry.ts index 44e12885201..1233cf2a278 100644 --- a/packages/app/src/composer/history/entry.ts +++ b/packages/app/src/composer/history/entry.ts @@ -1,6 +1,6 @@ import type { Prompt } from "@/composer/state" import type { SelectedLineRange } from "@/workspaces/files/model" -import { clonePrompt } from "../prompt-parts" +import { clonePrompt, isAttachment } from "../prompt-parts" import type { PromptHistoryComment, PromptHistoryEntry } from "../schema" export type { PromptHistoryComment, PromptHistoryEntry } from "../schema" @@ -35,9 +35,9 @@ export function prependHistoryEntry( .map((part) => ("content" in part ? part.content : "")) .join("") .trim() - const hasImages = prompt.some((part) => part.type === "image") + const hasAttachments = prompt.some(isAttachment) const hasComments = comments.some((comment) => !!comment.comment.trim()) - if (!text && !hasImages && !hasComments) return entries + if (!text && !hasAttachments && !hasComments) return entries const entry = { prompt: clonePrompt(prompt), @@ -86,7 +86,7 @@ function isPromptEqual(entryA: PromptHistoryStoredEntry, entryB: PromptHistorySt if (partA.type === "skill") { if (partB.type !== "skill" || partA.id !== partB.id || partA.name !== partB.name) return false } - if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false + if (isAttachment(partA) && partA.id !== (isAttachment(partB) ? partB.id : "")) return false } if (entryA.comments.length !== entryB.comments.length) return false for (let i = 0; i < entryA.comments.length; i++) { diff --git a/packages/app/src/composer/model.ts b/packages/app/src/composer/model.ts index 26bcb22dd40..211e7bd23bb 100644 --- a/packages/app/src/composer/model.ts +++ b/packages/app/src/composer/model.ts @@ -17,12 +17,12 @@ import { showToast } from "@/shell/notifications/toast" import { formatServerError } from "@/runtime/server/errors" import { Skill } from "@opencode/schema/skill" import type { ComposerAdapter, ComposerControls, ComposerQueue } from "./adapter" -import type { ImageAttachmentPart } from "./state" +import { isAttachment } from "./prompt-parts" import type { PromptHistoryComment } from "./history/entry" import { createComposerHistory } from "./history/store" import { composerPlaceholder } from "./placeholder" import { createComposerSubmit } from "./submit" -import { useAttachmentDestination } from "./attachments/deliver" +import { useAttachmentDestination } from "./attachments/destination" export type ComposerModel = ComposerEditorModel & { readonly model: ComposerControls["model"] @@ -73,7 +73,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue? }, []) }) const attachments = createMemo(() => - prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), + prompt.current().filter(isAttachment), ) const commentCount = createMemo(() => { if (mode() === "shell") return 0 @@ -266,7 +266,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue? resetHistory: () => controller.resetHistory(), setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }), closePopover: () => controller.dispatch({ type: "popover.close" }), - destination: useAttachmentDestination(adapter.controls), delivery: (alternate) => { const queue = options?.queue if (!queue) return "steer" @@ -321,8 +320,10 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue? onContextRemove(item) { if (item?.commentID) comments.remove(item.path, item.commentID) }, - openAttachment: (attachment) => - dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename })), + openAttachment: (attachment) => { + if (attachment.type !== "image") return + dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename })) + }, openContext(key) { const item = controller.contextItem(key) if (item) openComment(item, adapter.controls(), layout, files, comments) @@ -340,8 +341,15 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue? attachments: { picker: platform.openAttachmentPickerDialog, directory: () => sdk().directory, + destination: useAttachmentDestination(adapter.controls), isDialogActive: () => !!dialog.active, duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }), + onUploadError: (error) => + showToast({ + variant: "error", + title: language.t("prompt.toast.uploadFailed.title"), + description: composerErrorMessage(language, error), + }), onError: (error) => showToast({ variant: "error", diff --git a/packages/app/src/composer/prompt-parts.ts b/packages/app/src/composer/prompt-parts.ts index e616b484b83..0ab4e2e8f51 100644 --- a/packages/app/src/composer/prompt-parts.ts +++ b/packages/app/src/composer/prompt-parts.ts @@ -1,4 +1,9 @@ -import type { Prompt } from "./state" +import type { ContentPart, ImageAttachmentPart, PathAttachmentPart, Prompt } from "./state" + +/** Parts that sit beside the text rather than inside it. */ +export function isAttachment(part: ContentPart): part is ImageAttachmentPart | PathAttachmentPart { + return part.type === "image" || part.type === "path" +} export function clonePrompt(prompt: Prompt): Prompt { return prompt.map((part) => @@ -17,7 +22,7 @@ export function appendPrompt(prompt: Prompt, following: Prompt): Prompt { ...clonePrompt(prompt), { type: "text", content: "\n\n", start, end: offset }, ...clonePrompt(following).map((part) => - part.type === "image" ? part : { ...part, start: part.start + offset, end: part.end + offset }, + isAttachment(part) ? part : { ...part, start: part.start + offset, end: part.end + offset }, ), ] } diff --git a/packages/app/src/composer/request.test.ts b/packages/app/src/composer/request.test.ts index 7a605647bfc..879989d7ae8 100644 --- a/packages/app/src/composer/request.test.ts +++ b/packages/app/src/composer/request.test.ts @@ -1,15 +1,10 @@ import { describe, expect, test } from "bun:test" import { Skill } from "@opencode/schema/skill" import type { ImageAttachmentPart, Prompt } from "@/composer/state" -import type { DeliveredAttachment } from "./attachments/deliver" import { buildPromptRequest } from "./request" -function inline(filename: string, mime: string, extra?: Partial): DeliveredAttachment { - return { - type: "inline", - attachment: { type: "image", id: `img_${filename}`, filename, mime, blob: { id: filename, url: "" }, ...extra }, - dataUrl: `data:${mime};base64,AAA`, - } +function inline(filename: string, mime: string, extra?: Partial) { + return { type: "image" as const, id: `img_${filename}`, filename, mime, dataUrl: `data:${mime};base64,AAA`, ...extra } } describe("buildPromptRequest", () => { @@ -30,7 +25,7 @@ describe("buildPromptRequest", () => { const result = buildPromptRequest({ prompt, context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }], - attachments: [inline("a.png", "image/png")], + images: [inline("a.png", "image/png")], text: "hello @src/foo.ts @planner", sessionDirectory: "/repo", }) @@ -52,7 +47,7 @@ describe("buildPromptRequest", () => { const result = buildPromptRequest({ prompt: [{ type: "text", content: "check these", start: 0, end: 11 }], context: [], - attachments: [inline("a.png", "image/png"), inline("b.pdf", "application/pdf")], + images: [inline("a.png", "image/png"), inline("b.pdf", "application/pdf")], text: "check these", sessionDirectory: "/repo", }) @@ -67,7 +62,7 @@ describe("buildPromptRequest", () => { const result = buildPromptRequest({ prompt: [], context: [], - attachments: [ + images: [ inline("opencode.global.dat", "text/plain", { sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat", }), @@ -95,7 +90,7 @@ describe("buildPromptRequest", () => { }, ], context: [], - attachments: [], + images: [], text: "@docs", sessionDirectory: "/repo/app", }) @@ -117,7 +112,7 @@ describe("buildPromptRequest", () => { { key: "ctx:dup", type: "file", path: "src/foo.ts" }, { key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" }, ], - attachments: [], + images: [], text: "@src/foo.ts", sessionDirectory: "/repo", }) @@ -139,7 +134,7 @@ describe("buildPromptRequest", () => { comment: "Compare with @src/shared.ts and @src/review.ts.", }, ], - attachments: [], + images: [], text: "look", sessionDirectory: "/repo", }) @@ -155,7 +150,7 @@ describe("buildPromptRequest", () => { const result = buildPromptRequest({ prompt, context: [], - attachments: [], + images: [], text: "@src\\foo.ts", sessionDirectory: "D:\\projects\\myapp", // Windows path }) @@ -176,7 +171,7 @@ describe("buildPromptRequest", () => { const result = buildPromptRequest({ prompt, context: [], - attachments: [], + images: [], text: "@file#name.txt", sessionDirectory: "C:\\Users\\test\\Documents", // Windows path }) @@ -197,7 +192,7 @@ describe("buildPromptRequest", () => { const result = buildPromptRequest({ prompt, context: [], - attachments: [], + images: [], text: "@src/app.ts", sessionDirectory: "/home/user/project", }) @@ -211,7 +206,7 @@ describe("buildPromptRequest", () => { const result = buildPromptRequest({ prompt, context: [], - attachments: [], + images: [], text: "@README.md", sessionDirectory: "/Users/kelvin/Projects/opencode", }) @@ -226,7 +221,7 @@ describe("buildPromptRequest", () => { { key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" }, { key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" }, ], - attachments: [], + images: [], text: "test", sessionDirectory: "D:\\workspace\\app", }) @@ -248,7 +243,7 @@ describe("buildPromptRequest", () => { const result = buildPromptRequest({ prompt, context: [], - attachments: [], + images: [], text: "@D:\\other\\project\\file.ts", sessionDirectory: "C:\\current\\project", }) @@ -275,7 +270,7 @@ describe("buildPromptRequest", () => { const result = buildPromptRequest({ prompt, context: [], - attachments: [], + images: [], text: "@src\\App.tsx", sessionDirectory: "C:\\project", }) @@ -300,7 +295,7 @@ describe("buildPromptRequest", () => { const result = buildPromptRequest({ prompt, context: [], - attachments: [], + images: [], text: "@..\\..\\shared\\util.ts", sessionDirectory: "C:\\projects\\myapp\\src", }) @@ -330,7 +325,7 @@ describe("buildPromptRequest", () => { }, ], context: [], - attachments: [], + images: [], text: "@review", sessionDirectory: "/repo", }) diff --git a/packages/app/src/composer/request.ts b/packages/app/src/composer/request.ts index 555e16d45f5..b9c6f23e161 100644 --- a/packages/app/src/composer/request.ts +++ b/packages/app/src/composer/request.ts @@ -1,14 +1,13 @@ import { getFilename } from "@opencode/util/path" import type { FileSelection } from "@/workspaces/files/model" import { encodeFilePath } from "@/workspaces/files/path" -import type { AgentPart, FileAttachmentPart, Prompt, SkillPart } from "@/composer/state" +import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, PathAttachmentPart, Prompt, SkillPart } from "@/composer/state" import { formatAttachmentReference, formatCommentNote, type PromptAttachmentReference, type PromptComment, } from "@/composer/comment-note" -import type { DeliveredAttachment } from "@/composer/attachments/deliver" // Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo. type PromptRequest = { @@ -35,7 +34,7 @@ type ContextFile = { type BuildPromptRequestInput = { prompt: Prompt context: ContextFile[] - attachments: DeliveredAttachment[] + images: (Omit & { dataUrl: string })[] text: string sessionDirectory: string } @@ -63,6 +62,7 @@ const parseCommentMentions = (comment: string) => { const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file" const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent" const isSkillAttachment = (part: Prompt[number]): part is SkillPart => part.type === "skill" +const isPathAttachment = (part: Prompt[number]): part is PathAttachmentPart => part.type === "path" export function buildPromptRequest(input: BuildPromptRequestInput): PromptRequest { const skills = input.prompt.filter(isSkillAttachment).map((attachment) => ({ @@ -113,15 +113,15 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques return [file, ...mentions] }) - const inline = input.attachments.flatMap((item) => - item.type === "inline" - ? [{ uri: item.dataUrl, mime: item.attachment.mime, name: item.attachment.sourcePath ?? item.attachment.filename }] - : [], - ) + const inline = input.images.map((attachment) => ({ + uri: attachment.dataUrl, + mime: attachment.mime, + name: attachment.sourcePath ?? attachment.filename, + })) // Like comments, path references reach the model as text and the message UI through metadata. - const attachments = input.attachments.flatMap((item) => - item.type === "path" ? [{ name: item.attachment.filename, mime: item.attachment.mime, path: item.path }] : [], - ) + const attachments = input.prompt + .filter(isPathAttachment) + .map((part) => ({ name: part.filename, mime: part.mime, path: part.path })) return { text: [ diff --git a/packages/app/src/composer/schema.ts b/packages/app/src/composer/schema.ts index 960736d9d90..af77371b1a8 100644 --- a/packages/app/src/composer/schema.ts +++ b/packages/app/src/composer/schema.ts @@ -94,7 +94,24 @@ export const ImageAttachmentPart = Schema.Struct({ ) export type ImageAttachmentPart = typeof ImageAttachmentPart.Type -export const ContentPart = Schema.Union([TextPart, FileAttachmentPart, AgentPart, SkillPart, ImageAttachmentPart]) +// A file the model receives as a path on the server: its bytes never enter the draft store. +export const PathAttachmentPart = Persistence.struct({ + type: Schema.Literal("path"), + id: Schema.String, + filename: Schema.String, + mime: Schema.String, + path: Schema.String, +}) +export type PathAttachmentPart = typeof PathAttachmentPart.Type + +export const ContentPart = Schema.Union([ + TextPart, + FileAttachmentPart, + AgentPart, + SkillPart, + ImageAttachmentPart, + PathAttachmentPart, +]) export type ContentPart = typeof ContentPart.Type export const Prompt = Persistence.array(ContentPart) export type Prompt = typeof Prompt.Type diff --git a/packages/app/src/composer/state.ts b/packages/app/src/composer/state.ts index 130bc201021..8865f209f2e 100644 --- a/packages/app/src/composer/state.ts +++ b/packages/app/src/composer/state.ts @@ -23,6 +23,7 @@ export type { FileAttachmentPart, FileContextItem, ImageAttachmentPart, + PathAttachmentPart, Prompt, PromptModel, SkillPart, diff --git a/packages/app/src/composer/submission-state.ts b/packages/app/src/composer/submission-state.ts index e1cc4d622b7..d747c4334b0 100644 --- a/packages/app/src/composer/submission-state.ts +++ b/packages/app/src/composer/submission-state.ts @@ -1,5 +1,5 @@ import type { ComposerState, ContextItem, Prompt } from "./state" -import { appendPrompt, clonePrompt } from "./prompt-parts" +import { appendPrompt, clonePrompt, isAttachment } from "./prompt-parts" export type ComposerStateTarget = ReturnType @@ -22,7 +22,7 @@ export function createComposerSubmission(input: { if (initial !== target) { initial.reset() // A preparing session may already have an unsent follow-up in its promoted composer. - if (preserveDraft && target.current().some((part) => part.type === "image" || part.content.length > 0)) + if (preserveDraft && target.current().some((part) => isAttachment(part) || part.content.length > 0)) following = clonePrompt(target.current()) } if (!following) target.reset() diff --git a/packages/app/src/composer/submit.test.ts b/packages/app/src/composer/submit.test.ts index 15d9ffefac9..d100291cd1f 100644 --- a/packages/app/src/composer/submit.test.ts +++ b/packages/app/src/composer/submit.test.ts @@ -3,7 +3,6 @@ import type { ModelSelection } from "@/providers/models/selection" import type { SessionMessageUser } from "@opencode/client/promise" import { Skill } from "@opencode/schema/skill" import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter" -import type { AttachmentDestination } from "./attachments/deliver" import { createMemoryComposerState } from "./state" import { createComposerSubmit } from "./submit" @@ -49,14 +48,6 @@ function controls(): ComposerControls { } } -const destination: AttachmentDestination = { - input: { image: true, pdf: true }, - local: false, - upload: async () => { - throw new Error("native attachments must not upload") - }, -} - function submitInput( adapter: ActiveComposerAdapter | NewSessionComposerAdapter, notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} }, @@ -73,7 +64,6 @@ function submitInput( resetHistory() {}, setMode() {}, closePopover() {}, - destination: () => destination, notify, comments: { capture: () => [], clear() {}, restore() {} }, }) diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index a7342e5415b..46ae186d825 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -8,7 +8,8 @@ import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSess import { createComposerSubmission } from "./submission-state" import { buildPromptRequest } from "./request" import { setCursorPosition } from "./editor/dom" -import { deliverAttachments, type AttachmentDestination } from "./attachments/deliver" +import { blobDataUrl } from "@/runtime/persistence/drafts" +import { isAttachment } from "./prompt-parts" import type { ModelSelection } from "@/providers/models/selection" const submitting = new WeakSet() @@ -34,7 +35,6 @@ type ComposerSubmitInput = { resetHistory: () => void setMode: (mode: "normal" | "shell") => void closePopover: () => void - destination: () => AttachmentDestination delivery?: (alternate: boolean) => ComposerDelivery notify: { missingSelection: () => void @@ -87,16 +87,10 @@ export function createComposerSubmit(input: ComposerSubmitInput) { const optimisticBusy = !input.adapter.working() if (optimisticBusy && input.adapter.kind === "new-session") session.data.session.setStatus(session.id, "running") - const sending = sendPrompt( - session, - value, - input.destination(), - input.adapter.controls().model.selection.trackSessionCommit, - () => { - if (optimisticBusy && input.adapter.kind === "active-session") - session.data.session.setStatus(session.id, "running") - }, - ).then( + const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => { + if (optimisticBusy && input.adapter.kind === "active-session") + session.data.session.setStatus(session.id, "running") + }).then( () => ({ ok: true as const }), (error) => ({ ok: false as const, error }), ) @@ -129,13 +123,9 @@ export function createComposerSubmit(input: ComposerSubmitInput) { if (command) { clearSubmission(input, submission) - void sendCommand( - session, - value, - command, - input.destination(), - input.adapter.controls().model.selection.trackSessionCommit, - ).catch((error) => failSubmission(input, session, "command", error, restore, value.id)) + void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch( + (error) => failSubmission(input, session, "command", error, restore, value.id), + ) return } } finally { @@ -162,6 +152,9 @@ function handoffMessage(value: ComposerSubmission): SessionMessageUser { })), metadata: { displayText: value.text, + attachments: value.prompt.flatMap((part) => + part.type === "path" ? [{ name: part.filename, mime: part.mime, path: part.path }] : [], + ), comments: value.context.flatMap((item) => item.comment?.trim() ? [ @@ -196,7 +189,7 @@ function readSubmission( if (mode === "shell" && !text.trim()) return const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image") const comments = context.filter((item) => !!item.comment?.trim()).length - if (!text.trim() && images.length === 0 && comments === 0) return + if (!text.trim() && !prompt.some(isAttachment) && comments === 0) return const controls = input.adapter.controls() const model = controls.model.selection.current() @@ -304,10 +297,9 @@ async function sendCommand( session: ComposerSession, value: ComposerSubmission, command: { command: string; arguments: string }, - destination: AttachmentDestination, track?: ModelSelection["trackSessionCommit"], ) { - const request = await buildSubmissionRequest(session, value, destination) + const request = await buildSubmissionRequest(session, value) // Like queued prompts, queued commands must not apply the composer's selection to active work. if (value.delivery === "steer") await applySelection(session, value.selection, track) await session.api.command({ @@ -346,11 +338,10 @@ async function applySelection( async function sendPrompt( session: ComposerSession, value: ComposerSubmission, - destination: AttachmentDestination, track: ModelSelection["trackSessionCommit"] | undefined, onAdmit: () => void, ) { - const request = await buildSubmissionRequest(session, value, destination) + const request = await buildSubmissionRequest(session, value) // Switching agent or model reconfigures the session immediately, and with it // the remainder of a running turn. A steer targets that turn, so its // selection applies now; a queued follow-up must not reconfigure the turn it @@ -384,15 +375,14 @@ async function sendPrompt( await sending } -async function buildSubmissionRequest( - session: ComposerSession, - value: ComposerSubmission, - destination: AttachmentDestination, -) { +async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) { + const images = await Promise.all( + value.images.map(async (attachment) => ({ ...attachment, dataUrl: await blobDataUrl(attachment.blob, attachment.mime) })), + ) return buildPromptRequest({ prompt: value.prompt, context: value.context, - attachments: await deliverAttachments(value.images, destination), + images, text: value.text, sessionDirectory: session.directory, }) diff --git a/packages/app/src/composer/suggestions/machine.ts b/packages/app/src/composer/suggestions/machine.ts index 9da41144d51..9668c9e555a 100644 --- a/packages/app/src/composer/suggestions/machine.ts +++ b/packages/app/src/composer/suggestions/machine.ts @@ -1,4 +1,5 @@ import type { ComposerHistoryEntry, ComposerPersistedState, ComposerSuggestion } from "../types" +import { isAttachment } from "../prompt-parts" export type ComposerInteractionState = { mode: "normal" | "shell" @@ -236,7 +237,7 @@ function populated(persisted: ComposerPersistedState) { return ( !!promptText(persisted).trim() || persisted.context.items.length > 0 || - persisted.prompt.some((part) => part.type === "file" || part.type === "image") + persisted.prompt.some((part) => part.type === "file" || isAttachment(part)) ) } diff --git a/packages/app/src/composer/types.ts b/packages/app/src/composer/types.ts index 327d4f81c46..281ab7fb079 100644 --- a/packages/app/src/composer/types.ts +++ b/packages/app/src/composer/types.ts @@ -1,9 +1,17 @@ -import type { AgentPart, ComposerStore, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "./state" +import type { + AgentPart, + ComposerStore, + FileAttachmentPart, + ImageAttachmentPart, + PathAttachmentPart, + Prompt, + SkillPart, +} from "./state" export type ComposerFilePart = FileAttachmentPart export type ComposerAgentPart = AgentPart export type ComposerSkillPart = SkillPart -export type ComposerAttachment = ImageAttachmentPart +export type ComposerAttachment = ImageAttachmentPart | PathAttachmentPart export type ComposerPrompt = Prompt export type ComposerComment = ComposerStore["context"]["items"][number] export type ComposerPersistedState = ComposerStore diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index b851008975e..6ec50e7b13d 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -366,6 +366,9 @@ export const dict = { "prompt.action.stop": "Stop", "prompt.toast.attachmentDuplicate.title": "This file has already been uploaded", + "prompt.toast.uploading.percent": "{{percent}}%", + "prompt.toast.uploading.cancel": "Cancel upload", + "prompt.toast.uploadFailed.title": "Upload failed", "prompt.toast.modelAgentRequired.title": "Select an agent and model", "prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.", "prompt.toast.worktreeCreateFailed.title": "Failed to create worktree", diff --git a/packages/app/src/runtime/persistence/drafts.ts b/packages/app/src/runtime/persistence/drafts.ts index b15cae780cc..e8c4713c069 100644 --- a/packages/app/src/runtime/persistence/drafts.ts +++ b/packages/app/src/runtime/persistence/drafts.ts @@ -421,17 +421,9 @@ function referenced(json: string) { return ids } -async function blobData(blob: BlobReference) { - const kept = retained.get(aliases.get(blob.id) ?? blob.id) - return kept ? kept.blob : await fetch(blob.url).then((response) => response.blob()) -} - -export async function blobBytes(blob: BlobReference) { - return new Uint8Array(await (await blobData(blob)).arrayBuffer()) -} - export async function blobDataUrl(blob: BlobReference, mime: string) { - const data = await blobData(blob) + const kept = retained.get(aliases.get(blob.id) ?? blob.id) + const data = kept ? kept.blob : await fetch(blob.url).then((response) => response.blob()) return new Promise((resolve, reject) => { const reader = new FileReader() reader.addEventListener("error", () => reject(reader.error)) diff --git a/packages/app/src/session/composer/controller.ts b/packages/app/src/session/composer/controller.ts index 4b7028a86cd..4832b00989f 100644 --- a/packages/app/src/session/composer/controller.ts +++ b/packages/app/src/session/composer/controller.ts @@ -2,7 +2,6 @@ import { createEffect, createMemo, on, type Accessor } from "solid-js" import type { ComposerControls } from "@/composer/adapter" import { setCursorPosition } from "@/composer/editor/dom" import { createComposerModel } from "@/composer/model" -import { useAttachmentDestination } from "@/composer/attachments/deliver" import { useSettings } from "@/settings/model" import { createActiveComposerAdapter } from "./adapter" import { createSessionQueue } from "./queue" @@ -30,7 +29,6 @@ export function createSessionComposerController(input: { draft: adapter.state, working: adapter.working, behavior: settings.general.followUpBehavior, - destination: useAttachmentDestination(input.controls), restoreFocus: (cursor) => { const target = editor if (!target) return diff --git a/packages/app/src/session/composer/queue.ts b/packages/app/src/session/composer/queue.ts index d634cbecbc8..793b29f0541 100644 --- a/packages/app/src/session/composer/queue.ts +++ b/packages/app/src/session/composer/queue.ts @@ -5,11 +5,11 @@ import type { SessionInboxInfo } from "@opencode/client/promise" import { SessionMessage } from "@opencode/schema/session-message" import type { ComposerDelivery } from "@/composer/adapter" import type { ComposerStateTarget } from "@/composer/submission-state" -import type { ImageAttachmentPart, Prompt } from "@/composer/state" -import { clonePrompt, promptLength } from "@/composer/prompt-parts" +import type { ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state" +import { clonePrompt, isAttachment, promptLength } from "@/composer/prompt-parts" import { buildPromptRequest } from "@/composer/request" -import { deliverAttachments, type AttachmentDestination } from "@/composer/attachments/deliver" -import { createLegacyBlobReference } from "@/runtime/persistence/drafts" +import { blobDataUrl, createLegacyBlobReference } from "@/runtime/persistence/drafts" +import { readPromptPresentation } from "@/composer/comment-note" import { useData } from "@/runtime/server/current" import { useServerSDK } from "@/runtime/server/client" import { useWorkspaceLocation } from "@/workspaces/location" @@ -30,7 +30,6 @@ export function createSessionQueue(input: { draft: ComposerStateTarget working: Accessor behavior: Accessor - destination: () => AttachmentDestination restoreFocus: (cursor: number) => void }) { const data = useData() @@ -61,7 +60,6 @@ export function createSessionQueue(input: { change.item, change.prompt, change.text, - input.destination(), ) // Admit before cancelling so a failed replacement never discards the original. const admitted = await data.session.prompt({ @@ -187,15 +185,15 @@ export function createSessionQueue(input: { if (!editing || mutation.isPending) return const prompt = clonePrompt(input.draft.current()) const text = prompt.map((part) => ("content" in part ? part.content : "")).join("") - const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image") - if (!text.trim() && !images.length) return cancelEdit() + const attachments = prompt.filter(isAttachment) + if (!text.trim() && !attachments.length) return cancelEdit() const item = queued().find((entry) => entry.id === editing.id) const original = item ? queuedPromptAttachments(item) : [] const pristine = item && text.trim() === queuedPromptText(item) && - images.length === original.length && - images.every((image, index) => image.id === original[index].id) + attachments.length === original.length && + attachments.every((attachment, index) => attachment.id === original[index].id) if (pristine && delivery === "queue") return cancelEdit() mutation.mutate({ type: "edit", @@ -251,7 +249,7 @@ export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original .map((item) => ({ id: item.id, text: queuedPromptText(item), - attachments: item.payload.files?.length ?? 0, + attachments: (item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0), })) } @@ -261,18 +259,32 @@ export function queuedPromptText(item: QueuedPrompt) { } // Inline attachments are the files the composer added itself, so they return -// to it as image parts that an edit can remove or extend. Mentions and -// `file://` context stay in the payload; see editedPromptInput. -export function queuedPromptAttachments(item: QueuedPrompt): ImageAttachmentPart[] { - return (item.payload.files ?? []) - .filter((file) => isComposerAttachment(file)) - .map((file, index) => ({ - type: "image", - id: `${item.id}:file:${index}`, - filename: file.name ?? "attachment", - mime: file.mime, - blob: createLegacyBlobReference(`data:${file.mime};base64,${file.data}`), - })) +// to it as image parts that an edit can remove or extend, and path references +// return as path parts. Mentions and `file://` context stay in the payload; see +// editedPromptInput. +export function queuedPromptAttachments(item: QueuedPrompt): (ImageAttachmentPart | PathAttachmentPart)[] { + return [ + ...(item.payload.files ?? []) + .filter((file) => isComposerAttachment(file)) + .map( + (file, index): ImageAttachmentPart => ({ + type: "image", + id: `${item.id}:file:${index}`, + filename: file.name ?? "attachment", + mime: file.mime, + blob: createLegacyBlobReference(`data:${file.mime};base64,${file.data}`), + }), + ), + ...(readPromptPresentation(item.payload.metadata)?.attachments ?? []).map( + (file, index): PathAttachmentPart => ({ + type: "path", + id: `${item.id}:path:${index}`, + filename: file.name, + mime: file.mime, + path: file.path, + }), + ), + ] } function isComposerAttachment(file: NonNullable[number]) { @@ -292,13 +304,13 @@ async function editedPromptInput( item: QueuedPrompt | undefined, prompt: Prompt, text: string, - destination: AttachmentDestination, ) { - const attachments = await deliverAttachments( - prompt.filter((part): part is ImageAttachmentPart => part.type === "image"), - destination, + const images = await Promise.all( + prompt + .filter((part): part is ImageAttachmentPart => part.type === "image") + .map(async (part) => ({ ...part, dataUrl: await blobDataUrl(part.blob, part.mime) })), ) - const request = buildPromptRequest({ prompt, context: [], attachments, text, sessionDirectory: directory }) + const request = buildPromptRequest({ prompt, context: [], images, text, sessionDirectory: directory }) const payload = item?.payload const display = item ? queuedPromptText(item) : "" const notes = payload && display && payload.text.startsWith(display) ? payload.text.slice(display.length) : "" diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx index 0062a843854..92510edb21d 100644 --- a/packages/app/src/shell/shell.tsx +++ b/packages/app/src/shell/shell.tsx @@ -5,6 +5,7 @@ import { ResizeHandle } from "@opencode/ui/resize-handle" import { Titlebar, type TitlebarUpdate } from "@/shell/titlebar/titlebar" import { usePlatform } from "@/runtime/platform/platform" import { ToastRegion } from "@/shell/notifications/toast" +import { UploadToastHost } from "@/composer/attachments/uploads" import { TitlebarRightProvider } from "@/shell/titlebar/right-slot" import { useSettingsSurface } from "@/settings/surface" import { useSettings } from "@/settings/model" @@ -111,6 +112,7 @@ export default function Layout(props: ParentProps) { + ) diff --git a/packages/app/test-browser/composer-attachment-ownership.test.tsx b/packages/app/test-browser/composer-attachment-ownership.test.tsx index 1a7fc26321d..e6ad83636f8 100644 --- a/packages/app/test-browser/composer-attachment-ownership.test.tsx +++ b/packages/app/test-browser/composer-attachment-ownership.test.tsx @@ -34,10 +34,15 @@ describe("Composer attachment ownership", () => { addPart: () => false, setDraggingType() {}, directory: () => "C:/repo", + destination: () => ({ + input: { image: true, pdf: true }, + local: false, + upload: () => Promise.reject(new Error("native attachments must not upload")), + }), isDialogActive: () => false, - warn() {}, duplicate() {}, onError: rejectTest, + onUploadError: rejectTest, store: () => stored.promise, }) diff --git a/packages/ui/src/feedback/toast/toast.css b/packages/ui/src/feedback/toast/toast.css index db173489a9f..24fa71de120 100644 --- a/packages/ui/src/feedback/toast/toast.css +++ b/packages/ui/src/feedback/toast/toast.css @@ -79,6 +79,15 @@ grid-column: 2; } + /* Custom toasts render no close button, so their content takes the whole width. */ + &:not(:has(> [data-close-button])) > [data-content] { + grid-column: 1 / -1; + } + + [data-description]:empty { + display: none; + } + > [data-close-button] { grid-column: 2; grid-row: 1; diff --git a/packages/ui/src/feedback/toast/toast.tsx b/packages/ui/src/feedback/toast/toast.tsx index 7c966e4cefc..554f03b7728 100644 --- a/packages/ui/src/feedback/toast/toast.tsx +++ b/packages/ui/src/feedback/toast/toast.tsx @@ -140,13 +140,30 @@ export const Toast = Object.assign(ToastRoot, { let toastV2Id = 0 export const toaster = { - show(render: (props: { toastId: number }) => JSX.Element, options?: { duration?: number; persistent?: boolean }) { + show( + render: (props: { toastId: number }) => JSX.Element, + options?: { + duration?: number + persistent?: boolean + /** Reactive value the toast's height depends on; sonner re-measures when it changes. */ + resize?: () => unknown + }, + ) { const toastId = --toastV2Id toast.custom((id) => render({ toastId: Number(id) }), { id: toastId, className: "toast-v2", duration: options?.persistent ? Number.POSITIVE_INFINITY : options?.duration, unstyled: true, + // Sonner only re-measures on title/description changes, so a function-valued description is + // the hook for a custom toast whose height depends on reactive state. It renders nothing; + // toast.css collapses the empty description element. + description: options?.resize + ? (): JSX.Element => { + options.resize?.() + return undefined + } + : undefined, }) return toastId }, diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 50261ccf8fe..b40e18bc565 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -147,6 +147,8 @@ const source = { "ui.promptInput.dropFiles.pdf": "Drop PDFs or files to add", "ui.promptInput.dropFiles.imagePdf": "Drop images, PDFs, or files to add", "ui.promptInput.removeAttachment": "Remove attachment", + "ui.promptInput.cancelUpload": "Cancel upload", + "ui.promptInput.uploading": "{{percent}}%", "ui.promptInput.label": "Prompt", "ui.promptInput.placeholder.shell": "Enter shell command…", "ui.promptInput.placeholder.normal": "Ask anything, {{slash}} for commands, {{at}} for context…",