Skip to content

feat(chat): right-click Copy image on transcript pictures - #482

Open
Adam-Dalloul wants to merge 3 commits into
xintaofei:mainfrom
Adam-Dalloul:feat/copy-transcript-image
Open

feat(chat): right-click Copy image on transcript pictures#482
Adam-Dalloul wants to merge 3 commits into
xintaofei:mainfrom
Adam-Dalloul:feat/copy-transcript-image

Conversation

@Adam-Dalloul

Copy link
Copy Markdown
Contributor

Right-clicking a picture in a sent (or generated) message only opened the conversation menu (Copy text / export / …). There was Download on hover, but no Copy image.

This adds a nested image menu, same bubbling contract as file-badge actions:

  • Copy image writes a PNG ClipboardItem (JPEG/webp/gif are converted so Chrome/Tauri accept the write)
  • Download image stays
  • Preview dialog also has Copy, and right-click there copies too

The conversation menu still appears when you right-click anywhere else in the transcript.

No secrets. One concern.

@Adam-Dalloul
Adam-Dalloul force-pushed the feat/copy-transcript-image branch from 4519a30 to 320c672 Compare August 16, 2026 17:03
@xintaofei

Copy link
Copy Markdown
Owner

Thanks for this — the premise is right, and I like that you modelled it on FileReferenceActions rather than inventing a new interaction. Right-clicking a transcript image today only reaches the conversation-wide menu (conversation-detail-panel.tsx wraps the whole transcript in a Radix ContextMenu, which preventDefaults the native one), so there genuinely is no way to copy a picture out of a conversation right now — only the hover Download. Worth doing.

I ran the gates on your branch: eslint on the changed files, tsc --noEmit, and the full vitest suite (313 files / 4170 tests) are all green.

Two things I'd like fixed before merge, plus a few smaller ones.

1. WebKit will reject the JPEG/WebP/GIF path (macOS desktop)

copy-image.ts:38 awaits rasterToPngBlob() — an <img> decode plus canvas.toBlob() — before reaching navigator.clipboard.write() on line 39. WebKit requires the write to be issued inside the user gesture; once a decode has been awaited the transient activation is gone and the write throws NotAllowedError. The desktop app runs on WKWebView, so this is the primary platform for every non-PNG image.

The PNG path is fine as written (nothing suspends before the write), but this fix makes both paths safe and is what WebKit documents:

const png =
  sourceType === "image/png"
    ? Promise.resolve(new Blob([bytes as BlobPart], { type: "image/png" }))
    : rasterToPngBlob(bytes, sourceType)
await navigator.clipboard.write([new ClipboardItem({ "image/png": png })])

ClipboardItem accepts a Promise<Blob> as its value, so the write is issued synchronously and the browser awaits the raster itself; Chromium takes the same shape. As a bonus it fixes an ordering bug: today, copying image A and then image B leaves A on the clipboard if A's raster finishes last.

2. In the server / Docker web mode, Copy image is always offered and always fails

Served over plain HTTP on a LAN (not loopback — localhost is a secure-context exception), navigator.clipboard and ClipboardItem are both undefined. That's exactly why src/lib/utils.ts carries installClipboardFallback() and copyTextFromMenu, and that fallback only backfills writeText, not write.

So canCopyImageToClipboard() returns false, the menu row renders anyway, and the click ends in a toast.error. You already export the predicate at copy-image.ts:11, but nothing outside its test consumes it — gating the menu row and the dialog button on it would stop the action being offered where it cannot work.

Better still for that case: render the children with a bare onContextMenu={(e) => e.stopPropagation()} and no Radix menu at all — keeping your non-mouse onPointerDown guard, since the transcript trigger arms its long-press from pointerdown and would otherwise still open on touch. The stopPropagation keeps the transcript-wide trigger from firing, and since nothing then calls preventDefault, the browser's own image menu takes over — and its "Copy image" works regardless of secure context.

Also worth fixing before merge

  • Translations. The three new keys carry the English strings in all nine non-English locales (zh-CN.json:2933 and friends). messages.test.ts only compares key sets, so it stays green — but the app ships 10 translated locales and these would go out as English. While you're there: ClipboardImageUnsupportedError's message (copy-image.ts:21) is hardcoded English and gets interpolated into copyImageFailed, so that half of the toast would stay English even after the JSON is translated. Worth giving the typed error its own message key.
  • Object URL leak, copy-image.ts:76-79: the if (!ctx) branch rejects without URL.revokeObjectURL(url). Every other exit path revokes.
  • A component test for ImageActions. Its sibling has file-reference-actions.test.tsx, which pins exactly the contract you copied: right-click opens the menu, the ancestor conversation menu does not open, a touch long-press doesn't arm the ancestor, a left click doesn't open it. You added data-image-actions="" — the same query hook that test uses — but nothing queries it yet, and that contract is the easiest thing to regress silently.

Non-blocking notes

  • handleCopy / handleDownload now live in three files with identical bodies; a small useImageActions(image) hook would collapse them. Related: copy failures surface as toasts while download failures use window.alert — pre-existing, but the two now sit next to each other in one menu.
  • The preview dialog's right-click copies immediately with no menu (image-preview-dialog.tsx:94). It does toast, so it isn't silent, but it's undiscoverable and inconsistent with the two-item menu on the transcript; reusing ImageActions there would be less code and more consistent.
  • The extra wrapper <div> changes which element is the flex item in generated-images-block.tsx. In narrow/column layout the bordered box used to stretch to the column width (flex align-items: stretch); now it shrink-wraps the image, and the inner inline-block picks up a line box (~4px under the image). Arguably nicer-looking, but it's an unintended change — letting ImageActions take a className, or rendering it via asChild, keeps the old box exactly. user-image-attachments.tsx is unaffected: its inner element is block-level.
  • Assistant markdown images aren't covered. Streamdown renders those itself (data-streamdown="image-wrapper", with its own hover Download button), so right-clicking one still gets the conversation menu. Fine as a follow-up — they're URLs rather than base64, so copyImageToClipboard would need a second path.
  • atob plus the per-byte loop, and then a full-resolution canvas re-encode, all run synchronously on the main thread. Fine for normal attachments; it could jank on a very large image.

Happy to take another look once the clipboard write ordering and the capability gate are in.

… works

The rasterized path awaited an image decode and a canvas encode before it
called `clipboard.write()`. WebKit only honours a write issued inside the
user gesture, so on the desktop app — which is WKWebView — copying a JPEG,
webp or gif spent the transient activation on the decode and then failed
with NotAllowedError. `ClipboardItem` takes a pending Blob, so the write
now goes out synchronously and the browser awaits the raster itself. Two
copies in a row also land in the order they were asked for now, rather than
the order their rasters happened to finish in.

`write()` reports a rejected representation as an error of its own, so the
real reason is captured on the way past and rethrown in its place. That
observer is attached before the write rather than chained into it: the
promise then always has a handler, so a write that fails first for an
unrelated reason can't leave the raster rejection unhandled.

Served over plain HTTP on a LAN, neither `ClipboardItem` nor
`clipboard.write` exists — that is why `installClipboardFallback` is there,
and it only backfills `writeText`. The row was offered anyway and could
only ever end in an error toast. `canCopyImageToClipboard` was already
exported for this and unused; it now decides whether the menu is built at
all. Where it isn't, the trigger still shields the transcript menu from the
event but stops short of preventing the default, so the browser's own image
menu takes over — and its Copy image has no secure-context requirement.

The nine non-English locales carried the English strings. They are
translated, and the typed error's developer text no longer reaches a toast:
it maps to a message of its own instead of being interpolated raw.

The rest is what the extra wrapper cost. `ImageActions` takes a className
and puts it on the trigger, so the styled box is the flex item its parent
laid out again — with its shrink-0, and without a stray line box under the
image. The copy/download pair moved into `useImageActions`, so the menu,
the hover button and the preview dialog report success and failure the same
way instead of drifting across three copies. Right-clicking the blown-up
preview opens that same menu rather than silently copying, through a render
prop that keeps ui/ free of message-specific imports.

Also: the raster released its object URL on every exit but one.

Assistant markdown images are still Streamdown's own; copying those needs a
URL fetch path and is left alone here.
@xintaofei

Copy link
Copy Markdown
Owner

Pushed the fixes onto this branch (0cedb4e7) so it's ready to go — thanks again for the feature, the shape of it was right and everything below is around the edges.

The two blockers

  • ClipboardItem now takes the pending Blob instead of the resolved one, so write() goes out synchronously and stays inside the user gesture — JPEG/webp/gif copying works on the desktop WKWebView, and two copies in a row land in click order. write() swallows a rejected representation's reason, so it's captured on the way past and rethrown; the observer is attached before the write rather than chained into it, so the raster rejection can't go unhandled if the write fails first for its own reason.
  • canCopyImageToClipboard() now decides whether the menu is built. Where it isn't (plain HTTP on a LAN), the trigger still shields the transcript menu but doesn't prevent the default, so the browser's own image menu takes over — its Copy image doesn't need a secure context. Your non-mouse pointerDown guard is kept on that branch too, otherwise a long-press would still reach the transcript trigger.

The rest

  • Nine locales translated; the typed error gets its own message key, so its English developer text never lands in a toast.
  • The !ctx branch was the one raster exit that didn't revoke its object URL.
  • ImageActions takes a className and puts it on the trigger, so the styled box is the flex item again — shrink-0 intact, no stray line box under the image, one less DOM node than before.
  • useImageActions() replaces the three copies of copy/download, which also puts download failures on toasts alongside copy instead of window.alert.
  • The preview dialog opens the same menu on right-click instead of copying silently, via a renderImage render prop so ui/ keeps out of message-land. Its menu stops click propagation, otherwise picking an action closed the preview.

Testsimage-actions.test.tsx now pins the same contract as file-reference-actions.test.tsx (right-click only, ancestor menu stays shut, long-press shielded, both clipboard branches), plus the two regression tests worth having: one asserts write() is called before the raster resolves, one asserts the object URL is released on the failure path. I checked both fail against the old code.

Green on eslint, tsc --noEmit, vitest (314 files / 4182 tests) and pnpm build.

One thing left alone deliberately: assistant markdown images are Streamdown's own component, and copying those means a URL fetch with CORS to think about — a separate change rather than something to bolt on here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants