fix(app): preview image attachments delivered by path (#57249)

Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-10-20 11:46:49 +10:00
committed by GitHub
Unverified
parent 1da2be4192
commit fa2888b12e
7 changed files with 100 additions and 55 deletions
@@ -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()
}
})
+12 -3
View File
@@ -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={[
+48 -46
View File
@@ -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<string | undefined>
onAttachmentRemove: (attachment: ComposerAttachment) => void
onUploadCancel?: (upload: Upload) => void
onCommentClick?: (comment: ComposerComment) => void
@@ -724,52 +725,53 @@ export function ComposerAttachments(props: {
)}
</For>
<For each={props.attachments}>
{(attachment) => (
<div class="relative group shrink-0">
<Tooltip
value={attachment.type === "path" ? attachment.path : attachment.filename}
placement="top"
contentClass="break-all"
>
<Show
when={attachment.type === "image" && attachment.mime.startsWith("image/") ? attachment : undefined}
fallback={
<AttachmentCard title={attachment.filename}>
{typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))}
</AttachmentCard>
}
>
{(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. */}
<Suspense fallback={<div class="w-[58px] h-[46px]" />}>
<img
src={url() ?? ""}
alt={attachment.filename}
class="w-[58px] h-[46px] rounded-[6px] object-cover"
onClick={(event) => props.onAttachmentClick?.(attachment, event.currentTarget)}
/>
</Suspense>
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
</>
)
}}
</Show>
</Tooltip>
<button
type="button"
onClick={() => props.onAttachmentRemove(attachment)}
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center hover-reveal group-hover:opacity-100"
aria-label={props.removeLabel}
>
<Icon name="outline-xmark" class="text-v2-icon-icon-contrast" />
</button>
</div>
)}
return (
<div class="relative group shrink-0">
<Tooltip
value={attachment.type === "path" ? attachment.path : attachment.filename}
placement="top"
contentClass="break-all"
>
<Show
when={image && !missing()}
fallback={
<AttachmentCard title={attachment.filename}>
{typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))}
</AttachmentCard>
}
>
<Show when={src()} fallback={<div class="w-[58px] h-[46px]" />}>
{(value) => (
<img
src={value()}
alt={attachment.filename}
class="w-[58px] h-[46px] rounded-[6px] object-cover"
onClick={(event) => props.onAttachmentClick?.(attachment, event.currentTarget)}
/>
)}
</Show>
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
</Show>
</Tooltip>
<button
type="button"
onClick={() => props.onAttachmentRemove(attachment)}
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center hover-reveal group-hover:opacity-100"
aria-label={props.removeLabel}
>
<Icon name="outline-xmark" class="text-v2-icon-icon-contrast" />
</button>
</div>
)
}}
</For>
<For each={props.uploads ?? []}>
{(upload) => (
@@ -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<ComposerSuggestion[]>
searchContextFiles: (query: string) => ComposerSuggestion[] | Promise<ComposerSuggestion[]>
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<string | undefined>
openContext?: (key: string) => void
onContextRemove?: (item: ComposerComment) => void
onQuoteComment?: (item: Extract<ComposerComment, { type: "quote" }>, 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<string | undefined> {
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)
},
+26 -5
View File
@@ -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<string, Promise<string | undefined>>()
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)
@@ -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) => {
+1 -1
View File
@@ -19,7 +19,7 @@ export async function readLocalImage(
path: string,
signal: AbortSignal,
): Promise<Blob | undefined> {
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() ?? "")