From 718fb9fdc40012cf6836c9d97cf6fc8f614dc185 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Wed, 21 Oct 2026 17:24:04 -0700
Subject: [PATCH] Extract shared image clipboard copy utility (#31600)
* Extract image clipboard copying into shared utility
* Await image reads before reporting clipboard copy success
---
...tiveChatResolvedView.context-menu.test.tsx | 53 ++++++++++++--
.../native-chat/native-chat-image-copy.ts | 5 +-
.../src/lib/clipboard-image-copy.test.ts | 73 +++++++++++++++++++
src/renderer/src/lib/clipboard-image-copy.ts | 6 ++
4 files changed, 126 insertions(+), 11 deletions(-)
create mode 100644 src/renderer/src/lib/clipboard-image-copy.test.ts
create mode 100644 src/renderer/src/lib/clipboard-image-copy.ts
diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.context-menu.test.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.context-menu.test.tsx
index 37a747f5cea..e2a84afc266 100644
--- a/src/renderer/src/components/native-chat/NativeChatResolvedView.context-menu.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.context-menu.test.tsx
@@ -144,21 +144,58 @@ describe('NativeChatResolvedView image menu ownership', () => {
expect(mocks.writeClipboardImage).not.toHaveBeenCalled()
})
- it('copies the original image through the visible bridge chat menu', async () => {
+ it.each(['thumbnail', 'preview'])(
+ 'copies the original image from the %s through the visible bridge chat menu',
+ async (surface) => {
+ mocks.fetch.mockResolvedValue({ ok: true, blob: async () => new Blob(['original image']) })
+ render()
+
+ if (surface === 'preview') {
+ fireEvent.click(screen.getByRole('button', { name: 'Image #1: View image: original.png' }))
+ }
+ const preview =
+ surface === 'preview' ? screen.getByRole('dialog', { name: 'original.png' }) : null
+ const image = preview
+ ? within(preview).getByRole('img')
+ : screen.getByRole('img', { name: 'original.png' })
+ fireEvent.contextMenu(image)
+ const copy = await screen.findByRole('menuitem', { name: 'Copy image' })
+ fireEvent.pointerDown(copy, { button: 0, pointerType: 'mouse' })
+ fireEvent.pointerUp(copy, { button: 0, pointerType: 'mouse' })
+ fireEvent.click(copy)
+
+ await waitFor(() =>
+ expect(mocks.writeClipboardImage).toHaveBeenCalledWith(
+ `data:image/png;base64,${Buffer.from('original image').toString('base64')}`
+ )
+ )
+ expect(mocks.fetch).toHaveBeenCalledWith('blob:original')
+ expect(mocks.success).toHaveBeenCalledWith('Image copied')
+ expect(mocks.error).not.toHaveBeenCalled()
+ if (preview) {
+ expect(preview).toBeInTheDocument()
+ expect(preview).toHaveAttribute('data-state', 'open')
+ }
+ }
+ )
+
+ it('reports a clipboard write failure without announcing success while the write is pending', async () => {
+ const write = Promise.withResolvers()
mocks.fetch.mockResolvedValue({ ok: true, blob: async () => new Blob(['original image']) })
+ mocks.writeClipboardImage.mockReturnValue(write.promise)
render()
fireEvent.contextMenu(screen.getByRole('img', { name: 'original.png' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'Copy image' }))
- await waitFor(() =>
- expect(mocks.writeClipboardImage).toHaveBeenCalledWith(
- `data:image/png;base64,${Buffer.from('original image').toString('base64')}`
- )
- )
- expect(mocks.fetch).toHaveBeenCalledWith('blob:original')
- expect(mocks.success).toHaveBeenCalledWith('Image copied')
+ await waitFor(() => expect(mocks.writeClipboardImage).toHaveBeenCalledOnce())
+ expect(mocks.success).not.toHaveBeenCalled()
expect(mocks.error).not.toHaveBeenCalled()
+
+ await act(async () => write.reject(new Error('Clipboard unavailable')))
+
+ expect(mocks.error).toHaveBeenCalledWith("Couldn't copy image", { description: undefined })
+ expect(mocks.success).not.toHaveBeenCalled()
})
it('removes the portaled menu and captured image when the retained bridge owner hides', async () => {
diff --git a/src/renderer/src/components/native-chat/native-chat-image-copy.ts b/src/renderer/src/components/native-chat/native-chat-image-copy.ts
index 32dd7581e14..50d0d8bd4b3 100644
--- a/src/renderer/src/components/native-chat/native-chat-image-copy.ts
+++ b/src/renderer/src/components/native-chat/native-chat-image-copy.ts
@@ -1,6 +1,6 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
-import { blobToBase64, convertImageBlobToPng } from '@/lib/image-blob-png'
+import { copyImageToClipboard } from '@/lib/clipboard-image-copy'
import { isWebClientLocation } from '@/lib/web-client-location'
import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../../shared/clipboard-image'
@@ -60,8 +60,7 @@ export function keepPreviewOpenForChatMenu(event: {
export async function copyNativeChatImage(image: NativeChatCopyImage): Promise {
try {
- const png = await convertImageBlobToPng(await image())
- await window.api.ui.writeClipboardImage(`data:image/png;base64,${await blobToBase64(png)}`)
+ await copyImageToClipboard(await image())
toast.success(translate('components.native-chat.composer.imageCopied', 'Image copied'))
} catch (error) {
toast.error(
diff --git a/src/renderer/src/lib/clipboard-image-copy.test.ts b/src/renderer/src/lib/clipboard-image-copy.test.ts
new file mode 100644
index 00000000000..031c84e7bd3
--- /dev/null
+++ b/src/renderer/src/lib/clipboard-image-copy.test.ts
@@ -0,0 +1,73 @@
+// @vitest-environment happy-dom
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { copyImageToClipboard } from './clipboard-image-copy'
+
+const PNG_BASE64 =
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII='
+const png = new Blob([Uint8Array.from(atob(PNG_BASE64), (character) => character.charCodeAt(0))], {
+ type: 'image/png'
+})
+const writeClipboardImage = vi.fn<(dataUrl: string) => Promise>()
+const decode = vi.fn<() => Promise>()
+
+beforeEach(() => {
+ writeClipboardImage.mockResolvedValue(undefined)
+ decode.mockResolvedValue(undefined)
+ vi.stubGlobal('api', { ui: { writeClipboardImage } })
+ vi.stubGlobal(
+ 'Image',
+ class {
+ src = ''
+ naturalWidth = 1
+ naturalHeight = 1
+ decode = decode
+ }
+ )
+ vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:decode-owned')
+ vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined)
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+ writeClipboardImage.mockReset()
+ decode.mockReset()
+})
+
+describe('copyImageToClipboard', () => {
+ it('writes the original PNG bytes and waits for the local clipboard write', async () => {
+ const write = Promise.withResolvers()
+ writeClipboardImage.mockReturnValue(write.promise)
+ let finished = false
+
+ const copying = copyImageToClipboard(png).then(() => {
+ finished = true
+ })
+
+ await vi.waitFor(() => {
+ expect(writeClipboardImage).toHaveBeenCalledWith(`data:image/png;base64,${PNG_BASE64}`)
+ })
+ expect(finished).toBe(false)
+
+ write.resolve()
+ await copying
+ expect(finished).toBe(true)
+ })
+
+ it('propagates a decode failure without writing to the clipboard', async () => {
+ const error = new Error('Invalid image')
+ decode.mockRejectedValue(error)
+
+ await expect(copyImageToClipboard(png)).rejects.toBe(error)
+
+ expect(writeClipboardImage).not.toHaveBeenCalled()
+ })
+
+ it('propagates a local clipboard write failure', async () => {
+ const error = new Error('Clipboard unavailable')
+ writeClipboardImage.mockRejectedValue(error)
+
+ await expect(copyImageToClipboard(png)).rejects.toBe(error)
+ })
+})
diff --git a/src/renderer/src/lib/clipboard-image-copy.ts b/src/renderer/src/lib/clipboard-image-copy.ts
new file mode 100644
index 00000000000..18d5bc0080f
--- /dev/null
+++ b/src/renderer/src/lib/clipboard-image-copy.ts
@@ -0,0 +1,6 @@
+import { blobToBase64, convertImageBlobToPng } from './image-blob-png'
+
+export async function copyImageToClipboard(blob: Blob): Promise {
+ const png = await convertImageBlobToPng(blob)
+ await window.api.ui.writeClipboardImage(`data:image/png;base64,${await blobToBase64(png)}`)
+}