+
+ )
+}
+
+/** The context-menu trigger wrapped around the image. */
+function trigger(): HTMLElement {
+ const element = document.querySelector("[data-image-actions]")
+ if (!element) throw new Error("expected a context-menu trigger on the image")
+ return element
+}
+
+function item(name: string): HTMLElement {
+ return screen.getByRole("menuitem", { name })
+}
+
+describe("ImageActions", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.canCopyImageToClipboard.mockReturnValue(true)
+ mocks.copyImageToClipboard.mockResolvedValue(undefined)
+ mocks.downloadImage.mockResolvedValue(true)
+ })
+
+ it("opens on right-click, and only on right-click", () => {
+ renderActions()
+ // A left click belongs to the thumbnail (it opens the preview).
+ fireEvent.click(trigger())
+ expect(screen.queryByRole("menu")).toBeNull()
+
+ // Radix suppresses the native menu to put its own in that place — the
+ // contrast with the no-clipboard case below, which leaves it alone.
+ expect(fireEvent.contextMenu(trigger())).toBe(false)
+ expect(screen.getByRole("menu")).toBeInTheDocument()
+ })
+
+ it("keeps the right-click from also opening the conversation menu", () => {
+ renderActions()
+ fireEvent.contextMenu(trigger())
+
+ expect(screen.getByRole("menu")).toBeInTheDocument()
+ // The transcript is wrapped in the conversation panel's own context menu;
+ // both opening at once is what this stopPropagation prevents.
+ expect(mocks.ancestorContextMenu).not.toHaveBeenCalled()
+ })
+
+ it("keeps a touch long-press from arming the conversation menu too", () => {
+ renderActions()
+ // jsdom's fireEvent.pointerDown drops `pointerType`, so pin it by hand.
+ const event = new MouseEvent("pointerdown", {
+ bubbles: true,
+ cancelable: true,
+ })
+ Object.defineProperty(event, "pointerType", { value: "touch" })
+ fireEvent(trigger(), event)
+
+ expect(mocks.ancestorPointerDown).not.toHaveBeenCalled()
+ })
+
+ it("copies the image and reports success", async () => {
+ renderActions()
+ fireEvent.contextMenu(trigger())
+ fireEvent.click(item("Copy image"))
+
+ await waitFor(() => {
+ expect(mocks.copyImageToClipboard).toHaveBeenCalledWith({
+ data: IMAGE.data,
+ mime_type: IMAGE.mime_type,
+ })
+ })
+ await waitFor(() => {
+ expect(mocks.toastSuccess).toHaveBeenCalledWith("Image copied")
+ })
+ })
+
+ it("reports an unsupported clipboard in the user's language, not ours", async () => {
+ mocks.copyImageToClipboard.mockRejectedValue(
+ new ClipboardImageUnsupportedError()
+ )
+ renderActions()
+ fireEvent.contextMenu(trigger())
+ fireEvent.click(item("Copy image"))
+
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith(
+ enMessages.Folder.chat.messageList.copyImageUnsupported
+ )
+ })
+ // The typed error's own English text must not reach the toast.
+ expect(mocks.toastError).not.toHaveBeenCalledWith(
+ expect.stringContaining("This environment cannot")
+ )
+ })
+
+ it("passes any other failure through with the browser's message", async () => {
+ mocks.copyImageToClipboard.mockRejectedValue(new Error("Denied"))
+ renderActions()
+ fireEvent.contextMenu(trigger())
+ fireEvent.click(item("Copy image"))
+
+ await waitFor(() => {
+ expect(mocks.toastError).toHaveBeenCalledWith(
+ "Could not copy image: Denied"
+ )
+ })
+ })
+
+ it("downloads from the menu", async () => {
+ renderActions()
+ fireEvent.contextMenu(trigger())
+ fireEvent.click(item("Download image"))
+
+ await waitFor(() => {
+ expect(mocks.downloadImage).toHaveBeenCalledWith({
+ data: IMAGE.data,
+ mime_type: IMAGE.mime_type,
+ suggestedName: IMAGE.name,
+ })
+ })
+ })
+
+ describe("without a usable clipboard (non-secure web context)", () => {
+ beforeEach(() => {
+ mocks.canCopyImageToClipboard.mockReturnValue(false)
+ })
+
+ it("offers no menu of its own, so the native image menu can appear", () => {
+ renderActions()
+ const event = fireEvent.contextMenu(trigger())
+
+ expect(screen.queryByRole("menu")).toBeNull()
+ // Not preventing the default is what lets the browser's own menu — which
+ // copies images with no secure-context requirement — take over.
+ expect(event).toBe(true)
+ expect(mocks.ancestorContextMenu).not.toHaveBeenCalled()
+ })
+
+ it("still shields the ancestor from a touch long-press", () => {
+ renderActions()
+ const event = new MouseEvent("pointerdown", {
+ bubbles: true,
+ cancelable: true,
+ })
+ Object.defineProperty(event, "pointerType", { value: "touch" })
+ fireEvent(trigger(), event)
+
+ expect(mocks.ancestorPointerDown).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/src/components/message/image-actions.tsx b/src/components/message/image-actions.tsx
new file mode 100644
index 000000000..45ae91019
--- /dev/null
+++ b/src/components/message/image-actions.tsx
@@ -0,0 +1,162 @@
+"use client"
+
+import { type ReactNode, useCallback } from "react"
+import { Copy, Download } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { toast } from "sonner"
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu"
+import {
+ canCopyImageToClipboard,
+ ClipboardImageUnsupportedError,
+ copyImageToClipboard,
+} from "@/lib/copy-image"
+import { downloadImage } from "@/lib/image-download"
+import { toErrorMessage } from "@/lib/app-error"
+import type { UserImageDisplay } from "@/lib/adapters/ai-elements-adapter"
+
+/**
+ * The copy/download pair behind every transcript image, shared by the
+ * right-click menu here, the hover button on the thumbnail and the preview
+ * dialog — one image can be reached three ways and all three should report
+ * success and failure identically.
+ *
+ * Both handlers take the image rather than closing over one, so a list of
+ * thumbnails can call the hook once and use it for every row.
+ */
+export function useImageActions(): {
+ canCopy: boolean
+ copy: (image: UserImageDisplay) => Promise
+ download: (image: UserImageDisplay) => Promise
+} {
+ const t = useTranslations("Folder.chat.messageList")
+
+ const copy = useCallback(
+ async (image: UserImageDisplay) => {
+ try {
+ await copyImageToClipboard({
+ data: image.data,
+ mime_type: image.mime_type,
+ })
+ toast.success(t("copiedImage"))
+ } catch (err) {
+ // Our own "can't be done here" carries an English developer string;
+ // anything else is a browser error worth showing verbatim.
+ toast.error(
+ err instanceof ClipboardImageUnsupportedError
+ ? t("copyImageUnsupported")
+ : t("copyImageFailed", { message: toErrorMessage(err) })
+ )
+ }
+ },
+ [t]
+ )
+
+ const download = useCallback(
+ async (image: UserImageDisplay) => {
+ try {
+ await downloadImage({
+ data: image.data,
+ mime_type: image.mime_type,
+ suggestedName: image.name,
+ })
+ } catch (err) {
+ toast.error(t("downloadFailed", { message: toErrorMessage(err) }))
+ }
+ },
+ [t]
+ )
+
+ return { canCopy: canCopyImageToClipboard(), copy, download }
+}
+
+/**
+ * Right-click menu on a transcript image: Copy image / Download image.
+ *
+ * The conversation panel wraps the whole transcript in its own context
+ * menu, so this trigger stops the event from bubbling — same contract as
+ * `FileReferenceActions`. Right-clicking the image is about the image;
+ * right-clicking anywhere else still gets the conversation menu.
+ *
+ * `className` lands on the trigger itself rather than on a wrapper around it,
+ * so callers keep the box they had before: the styled element stays the flex
+ * item its parent laid out, with its own `shrink-0` and display.
+ */
+export function ImageActions({
+ image,
+ className,
+ children,
+}: {
+ image: UserImageDisplay
+ className?: string
+ children: ReactNode
+}) {
+ const t = useTranslations("Folder.chat.messageList")
+ const { canCopy, copy, download } = useImageActions()
+
+ // Radix's own handler still runs on this element; only the ancestor
+ // conversation-panel trigger is cut off.
+ const stopContextMenu = (event: { stopPropagation: () => void }) =>
+ event.stopPropagation()
+ // Touch and pen open a context menu from a long press, which the ancestor
+ // arms on pointerdown — stop that press from reaching it. Mouse presses keep
+ // bubbling, so the panel's selection bookkeeping is untouched.
+ const stopNonMousePointerDown = (event: {
+ pointerType: string
+ stopPropagation: () => void
+ }) => {
+ if (event.pointerType !== "mouse") event.stopPropagation()
+ }
+
+ // Non-secure web context (the server build over plain HTTP on a LAN): no
+ // clipboard write exists, so a Copy row could only ever fail. Drop our menu
+ // and let the browser's own image menu through instead — it copies and saves
+ // images natively, with no secure-context requirement. Blocking the ancestor
+ // without calling preventDefault is what lets it appear.
+ if (!canCopy) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ return (
+
+
+
+ {children}
+
+
+ {/* The menu is portaled, but its click still bubbles through the React
+ tree to whatever the trigger sits inside — in the preview dialog that
+ is a backdrop that closes on click, which would shut the preview the
+ moment an action was picked. */}
+ event.stopPropagation()}>
+ void copy(image)}>
+
+ {t("copyImage")}
+
+ void download(image)}>
+
+ {t("downloadImage")}
+
+
+
+ )
+}
diff --git a/src/components/message/user-image-attachments.tsx b/src/components/message/user-image-attachments.tsx
index e5154fa23..18dd4e192 100644
--- a/src/components/message/user-image-attachments.tsx
+++ b/src/components/message/user-image-attachments.tsx
@@ -1,13 +1,12 @@
"use client"
-import { useCallback, useState } from "react"
+import { useState } from "react"
import Image from "next/image"
import { Download } from "lucide-react"
import { useTranslations } from "next-intl"
import type { UserImageDisplay } from "@/lib/adapters/ai-elements-adapter"
import { ImagePreviewDialog } from "@/components/ui/image-preview-dialog"
-import { downloadImage } from "@/lib/image-download"
-import { toErrorMessage } from "@/lib/app-error"
+import { ImageActions, useImageActions } from "./image-actions"
interface UserImageAttachmentsProps {
images: UserImageDisplay[]
@@ -20,22 +19,7 @@ export function UserImageAttachments({
}: UserImageAttachmentsProps) {
const t = useTranslations("Folder.chat.messageList")
const [previewIndex, setPreviewIndex] = useState(null)
-
- const handleDownload = useCallback(
- async (image: UserImageDisplay) => {
- try {
- await downloadImage({
- data: image.data,
- mime_type: image.mime_type,
- suggestedName: image.name,
- })
- } catch (err) {
- const message = toErrorMessage(err)
- window.alert(t("downloadFailed", { message }))
- }
- },
- [t]
- )
+ const { canCopy, copy, download } = useImageActions()
if (images.length === 0) return null
@@ -48,8 +32,9 @@ export function UserImageAttachments({