From fa2888b12e05e498d3141cffb59aaea88c24e195 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:46:49 +1000 Subject: [PATCH] fix(app): preview image attachments delivered by path (#57249) Co-authored-by: Hona <10430890+Hona@users.noreply.github.com> --- packages/app/component-tests/composer.spec.ts | 1 + .../app/src/composer/composer.stories.tsx | 15 ++- packages/app/src/composer/editor/editor.tsx | 94 ++++++++++--------- .../app/src/composer/editor/interaction.ts | 11 +++ packages/app/src/composer/model.ts | 31 +++++- packages/app/src/runtime/server/image.test.ts | 1 + packages/app/src/runtime/server/image.ts | 2 +- 7 files changed, 100 insertions(+), 55 deletions(-) diff --git a/packages/app/component-tests/composer.spec.ts b/packages/app/component-tests/composer.spec.ts index f3f42dfcfac..c4b2e21e45c 100644 --- a/packages/app/component-tests/composer.spec.ts +++ b/packages/app/component-tests/composer.spec.ts @@ -257,6 +257,7 @@ for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) { if (draft === "mixed-attachments") { await expect(component.getByAltText("layout.png")).toBeVisible() + await expect(component.getByAltText("screenshot.png")).toBeVisible() await expect(component.getByText("Keep the normal flow flat", { exact: true })).toBeVisible() } }) diff --git a/packages/app/src/composer/composer.stories.tsx b/packages/app/src/composer/composer.stories.tsx index 5c23a8059ae..9de88cc01a4 100644 --- a/packages/app/src/composer/composer.stories.tsx +++ b/packages/app/src/composer/composer.stories.tsx @@ -12,6 +12,8 @@ import { SessionPreview } from "@/session/story-model" import { Skill } from "@opencode/schema/skill" import { resolveSessionComposerSelection } from "@/session/composer/selection" +const pixel = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==" + const selectedModel = { id: STORY_MODEL.id, providerID: STORY_MODEL.providerID, @@ -147,6 +149,7 @@ function ComposerStory(props: { commands: () => commands, context: () => context, searchContextFiles: () => [], + readImage: async () => pixel, view: { placeholder: () => "Ask anything, / for commands, @ for context...", agent: { @@ -211,8 +214,7 @@ function ComposerStory(props: { : `Submitted: ${value}`, ) }, - onStop: () => - setStory("activity", props.continueOnStop ? "POST /interrupt · resume: true" : "Stop requested"), + onStop: () => setStory("activity", props.continueOnStop ? "POST /interrupt · resume: true" : "Stop requested"), }, }, }) @@ -278,7 +280,14 @@ export const MixedAttachments = { id: "image-story", filename: "layout.png", mime: "image/png", - blob: { id: "image-story", url: "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==" }, + blob: { id: "image-story", url: pixel }, + }, + { + type: "path", + id: "path-image-story", + filename: "screenshot.png", + mime: "image/png", + path: "C:/shots/screenshot.png", }, ]} comments={[ diff --git a/packages/app/src/composer/editor/editor.tsx b/packages/app/src/composer/editor/editor.tsx index 6b1295faafb..2032511ee75 100644 --- a/packages/app/src/composer/editor/editor.tsx +++ b/packages/app/src/composer/editor/editor.tsx @@ -18,7 +18,6 @@ import { FileIcon } from "@opencode/ui/file-icon" import { Icon } from "@opencode/ui/icon" import { IconButton } from "@opencode/ui/icon-button" import { createAnimatedPresence } from "@/runtime/animated-presence" -import { resolveBlobUrl } from "@/runtime/persistence/drafts" import { ProviderModelIcon } from "@/providers/models/provider-group" import { useI18n } from "@opencode/ui/context/i18n" import { Button } from "@opencode/ui/button" @@ -224,6 +223,7 @@ export function ComposerEditor(props: ComposerEditorProps) { activeCommentID={state.activeContextID} removeLabel={i18n.t("ui.promptInput.removeAttachment")} onAttachmentClick={props.controller.openAttachment} + preview={props.controller.previewAttachment} onAttachmentRemove={(attachment) => props.controller.removeAttachment(attachment.id)} onUploadCancel={(upload) => props.controller.cancelUpload(upload.id)} onCommentClick={(comment) => props.controller.toggleContext(comment.key)} @@ -608,6 +608,7 @@ export function ComposerAttachments(props: { activeCommentID?: string removeLabel: string onAttachmentClick?: (attachment: ComposerAttachment, origin: HTMLElement) => void + preview: (attachment: ComposerAttachment) => Promise onAttachmentRemove: (attachment: ComposerAttachment) => void onUploadCancel?: (upload: Upload) => void onCommentClick?: (comment: ComposerComment) => void @@ -724,52 +725,53 @@ export function ComposerAttachments(props: { )} - {(attachment) => ( -
- - - {typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))} - - } - > - {(image) => { - // Restored drafts and history carry image ids only; bytes load when shown. - const [url] = createResource(() => image().blob, resolveBlobUrl) + {(attachment) => { + const image = attachment.mime.startsWith("image/") + // Restored drafts and history carry image ids only, and path attachments carry no bytes; both load when shown. + const [url] = createResource(() => image && attachment, props.preview) + // Read state rather than the resource so loading never suspends; the route boundary would detach the screen and drop editor focus. + const src = () => (url.state === "ready" ? url() : undefined) + const missing = () => url.state === "errored" || (url.state === "ready" && !url()) - return ( - <> - {/* Keep loading local; the route boundary would detach the screen and drop editor focus. */} - }> - {attachment.filename} props.onAttachmentClick?.(attachment, event.currentTarget)} - /> - -
- - ) - }} - - - -
- )} + return ( +
+ + + {typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))} + + } + > + }> + {(value) => ( + {attachment.filename} props.onAttachmentClick?.(attachment, event.currentTarget)} + /> + )} + +
+ + + +
+ ) + }} {(upload) => ( diff --git a/packages/app/src/composer/editor/interaction.ts b/packages/app/src/composer/editor/interaction.ts index 974941435ec..a119bde41be 100644 --- a/packages/app/src/composer/editor/interaction.ts +++ b/packages/app/src/composer/editor/interaction.ts @@ -4,6 +4,7 @@ import { createStore, reconcile } from "solid-js/store" import { useFilteredList } from "@opencode/ui/hooks" import { createComposerAttachments, type ComposerAttachmentConfig } from "../attachments/attachments" import type { Upload } from "../attachments/uploads" +import { resolveBlobUrl } from "@/runtime/persistence/drafts" import { createComposerEditorActions, type ComposerStateStoreInput } from "./actions" import type { ComposerAttachment, @@ -65,6 +66,8 @@ export function createComposerEditor(input: { context: Accessor searchContextFiles: (query: string) => ComposerSuggestion[] | Promise openAttachment?: (attachment: ComposerAttachment, origin?: HTMLElement) => void + /** An object URL for the image file at this path, which a path attachment references without its bytes. */ + readImage?: (path: string) => Promise openContext?: (key: string) => void onContextRemove?: (item: ComposerComment) => void onQuoteComment?: (item: Extract, comment: string) => void @@ -425,6 +428,14 @@ export function createComposerEditor(input: { openAttachment(attachment: ComposerAttachment, origin?: HTMLElement) { input.openAttachment?.(attachment, origin) }, + /** The image an attachment shows, whether its bytes are in the draft store or in a file at its path. */ + previewAttachment(attachment: ComposerAttachment): Promise { + if (!attachment.mime.startsWith("image/")) return Promise.resolve(undefined) + + if (attachment.type === "image") return resolveBlobUrl(attachment.blob) + + return input.readImage?.(attachment.path) ?? Promise.resolve(undefined) + }, removeAttachment(id: string) { draft.removeAttachment(id) }, diff --git a/packages/app/src/composer/model.ts b/packages/app/src/composer/model.ts index 00edbd8e5f0..b61725d798c 100644 --- a/packages/app/src/composer/model.ts +++ b/packages/app/src/composer/model.ts @@ -2,7 +2,7 @@ import { ImagePreview } from "@opencode/ui/image-preview" import { useDialog } from "@opencode/ui/context/dialog" import type { ReferenceInfo } from "@opencode/client/promise" import type { Links, MountedSession } from "@opencode/gui-extensions/sdk" -import { createComponent, createEffect, createMemo, on } from "solid-js" +import { createComponent, createEffect, createMemo, on, onCleanup } from "solid-js" import { Predicate } from "effect" import type { ComposerSuggestion } from "./types" import { createComposerEditor, createComposerEditorState, type ComposerEditorModel } from "./editor/interaction" @@ -15,8 +15,9 @@ import { useExtensionHost } from "@/runtime/extension/host" import { useExtensionAttachment } from "@/runtime/extension/host-apis" import { usePlatform } from "@/runtime/platform/platform" import { useWorkspaceLocation } from "@/workspaces/location" -import { resolveBlobUrl } from "@/runtime/persistence/drafts" import { useData, useServer } from "@/runtime/server/current" +import { useServerSDK } from "@/runtime/server/client" +import { readLocalImage } from "@/runtime/server/image" import { showToast } from "@/shell/notifications/toast" import { formatServerError } from "@/runtime/server/errors" import { Skill } from "@opencode/schema/skill" @@ -56,8 +57,16 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue? const language = useLanguage() const settings = useSettings() const platform = usePlatform() + const serverSDK = useServerSDK() const prompt = adapter.state let editor: HTMLDivElement | undefined + // A path attachment's image is read through the server, which can see both local and uploaded paths. + const pathImages = new Map>() + const pathImageReads = new AbortController() + onCleanup(() => { + pathImageReads.abort() + pathImages.forEach((url) => void url.then((value) => value && URL.revokeObjectURL(value))) + }) const interaction = createComposerEditorState(prompt.mode.current()) createEffect( @@ -333,12 +342,24 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue? onContextRemove(item) { if (item.type === "file" && item.commentID) comments.remove(item.path, item.commentID) }, - openAttachment: (attachment, origin) => { - if (attachment.type !== "image") return - void resolveBlobUrl(attachment.blob).then((src) => { + openAttachment(attachment, origin) { + void controller.previewAttachment(attachment).then((src) => { if (src) dialog.show(() => createComponent(ImagePreview, { src, alt: attachment.filename, origin })) }) }, + readImage(path) { + const existing = pathImages.get(path) + + if (existing) return existing + const url = readLocalImage(serverSDK.api, sdk().directory, path, pathImageReads.signal) + .then((blob) => blob && URL.createObjectURL(blob)) + .catch(() => undefined) + pathImages.set(path, url) + // A failed read retries the next time the attachment shows. + void url.then((value) => value || pathImages.delete(path)) + + return url + }, openContext(key) { const item = controller.contextItem(key) diff --git a/packages/app/src/runtime/server/image.test.ts b/packages/app/src/runtime/server/image.test.ts index 05b469f88d1..97ad9a7a026 100644 --- a/packages/app/src/runtime/server/image.test.ts +++ b/packages/app/src/runtime/server/image.test.ts @@ -26,6 +26,7 @@ describe("readLocalImage", () => { ["d:/screen.png", "d:/", "screen.png"], ["D:/charts/screen.png", "D:/charts/", "screen.png"], ["Z:/charts/screen.png", "Z:/charts/", "screen.png"], + ["C:\\Users\\me\\screen #1.png", "C:/Users/me/", "screen%20%231.png"], ["/tmp/opencode/screen #1.png", "/tmp/opencode/", "screen%20%231.png"], ["/screen.png", "/", "screen.png"], ])("reads %s with the correct location and authentication", async (path, directory, encoded) => { diff --git a/packages/app/src/runtime/server/image.ts b/packages/app/src/runtime/server/image.ts index 1f3d9d2c57c..ea24956907a 100644 --- a/packages/app/src/runtime/server/image.ts +++ b/packages/app/src/runtime/server/image.ts @@ -19,7 +19,7 @@ export async function readLocalImage( path: string, signal: AbortSignal, ): Promise { - const drive = /^[a-z]:\//i.test(path) + const drive = /^[a-z]:[\\/]/i.test(path) if (/^[\\/]{2}/.test(path) || (!drive && /^[a-z][a-z\d+.-]*:/i.test(path))) return const type = types.get(path.match(/\.([^./]+)$/)?.[1]?.toLowerCase() ?? "")