diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx index 6577b4d1fff..286365db217 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { NativeChatImageAttachmentPreview } from './NativeChatImageAttachmentPreview' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' @@ -29,6 +29,15 @@ function renderPreview(attachment: NativeChatComposerImageAttachment): void { render() } +async function clickOn(target: Element): Promise { + await act(async () => { + target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 })) + target.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, button: 0 })) + target.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 })) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + describe('NativeChatImageAttachmentPreview', () => { it('shows the clipboard thumbnail and a spinner while pending', () => { mocks.useLocalImageSrc.mockReturnValue(undefined) @@ -56,6 +65,65 @@ describe('NativeChatImageAttachmentPreview', () => { }) }) + it('offers the full-size file, not the clipboard thumbnail, to the chat copy menu', () => { + mocks.useLocalImageSrc.mockReturnValue('blob:on-disk-1') + renderPreview({ id: 'a1', path: '/tmp/example.png', previewUrl: 'data:thumbnail' }) + + const thumbnail = screen.getByRole('button', { name: 'View image: example.png' }) + expect(thumbnail.getAttribute('data-native-chat-copy-image-src')).toBe('blob:on-disk-1') + }) + + it('offers nothing to copy until the file is readable or in the web client', () => { + mocks.useLocalImageSrc.mockReturnValue(undefined) + renderPreview({ id: 'a1', path: '/tmp/example.png', previewUrl: 'data:thumbnail' }) + expect( + screen + .getByRole('button', { name: 'View image: example.png' }) + .hasAttribute('data-native-chat-copy-image-src') + ).toBe(false) + + mocks.useLocalImageSrc.mockReturnValue('blob:on-disk-2') + renderPreview({ id: 'a2', path: '/tmp/logo.SVG' }) + expect( + screen + .getByRole('button', { name: 'View image: logo.SVG' }) + .hasAttribute('data-native-chat-copy-image-src') + ).toBe(true) + + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + renderPreview({ id: 'a3', path: '/tmp/shot.png' }) + expect( + screen + .getByRole('button', { name: 'View image: shot.png' }) + .hasAttribute('data-native-chat-copy-image-src') + ).toBe(false) + }) + + it('stays open through a click on the chat context menu, but not elsewhere outside', async () => { + mocks.useLocalImageSrc.mockReturnValue('blob:on-disk-1') + renderPreview({ id: 'a1', path: '/tmp/example.png', previewUrl: 'data:thumbnail' }) + fireEvent.click(screen.getByRole('button', { name: 'View image: example.png' })) + expect( + within(screen.getByRole('dialog')) + .getByRole('img') + .getAttribute('data-native-chat-copy-image-src') + ).toBe('blob:on-disk-1') + const menu = document.body.appendChild(document.createElement('div')) + menu.setAttribute('data-native-chat-context-menu', '') + const copyItem = menu.appendChild(document.createElement('div')) + const elsewhere = document.body.appendChild(document.createElement('div')) + // Radix arms its outside-pointer listener a tick after the dialog opens. + await act(async () => new Promise((resolve) => setTimeout(resolve, 0))) + + await clickOn(copyItem) + expect(screen.queryByRole('dialog')).toBeTruthy() + + await clickOn(elsewhere) + expect(screen.queryByRole('dialog')).toBeNull() + menu.remove() + elsewhere.remove() + }) + it('shows a pasted image that could not come back by name, says it was not kept, and lets it be removed', () => { mocks.useLocalImageSrc.mockReturnValue(undefined) const onRemove = vi.fn() diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx index 4c7971047bb..0ef722cd3a8 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx @@ -4,6 +4,7 @@ import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/compone import { translate } from '@/i18n/i18n' import { basename } from '@/lib/path' import { useLocalImageSrc } from '@/components/editor/useLocalImageSrc' +import { copyableNativeChatImageSrc, keepPreviewOpenForChatMenu } from './native-chat-image-copy' import { isNativeChatPastedImagePath } from './native-chat-image-paste' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' import { chatImageAccess } from '@/lib/local-file-access' @@ -141,6 +142,8 @@ function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.El 'Saving pasted image…' ) const label = isPending ? pendingLabel : filename + // The thumbnail may be the downscaled clipboard preview; copy only the file. + const copySrc = copyableNativeChatImageSrc(localSrc) return ( <> @@ -154,6 +157,7 @@ function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.El } aria-busy={isPending} title={label} + data-native-chat-copy-image-src={copySrc} onClick={() => setIsOpen(true)} className="flex size-full items-center justify-center overflow-hidden rounded-md border border-border bg-chat-canvas transition-colors hover:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > @@ -175,7 +179,10 @@ function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.El onRemove(attachment.id)} /> - + {label} {translate('components.native-chat.composer.imagePreview', 'Full-size image preview')} @@ -185,6 +192,7 @@ function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.El {label} ) : ( 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 new file mode 100644 index 00000000000..84c782a3101 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.context-menu.test.tsx @@ -0,0 +1,176 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ImageBlobPng from '@/lib/image-blob-png' +import { NativeChatImageAttachmentPreview } from './NativeChatImageAttachmentPreview' +import type { NativeChatLiveSession } from './use-native-chat-live-session' + +const mocks = vi.hoisted(() => ({ + fetch: vi.fn(), + convertImageBlobToPng: vi.fn(), + writeClipboardImage: vi.fn(), + success: vi.fn(), + error: vi.fn() +})) + +vi.mock('./use-native-chat-retained-session', () => ({ + useNativeChatRetainedSession: (): NativeChatLiveSession => ({ + messages: [], + status: 'ready', + sessionId: 'session-menu', + agent: 'claude', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' + }) +})) +vi.mock('./NativeChatComposer', () => ({ + NativeChatComposer: () => ( + <> + +

Chat text

+ + ) +})) +vi.mock('@/components/editor/useLocalImageSrc', () => ({ + useLocalImageSrc: () => 'blob:original' +})) +vi.mock('@/lib/image-blob-png', async (importOriginal) => ({ + ...(await importOriginal()), + convertImageBlobToPng: mocks.convertImageBlobToPng +})) +vi.mock('sonner', () => ({ toast: { success: mocks.success, error: mocks.error } })) + +const { NativeChatResolvedView } = await import('./NativeChatResolvedView') +const { useAppStore } = await import('../../store') + +function RetainedBridgeChat({ visible = true }: { visible?: boolean }) { + return ( +
+ +
+ ) +} + +beforeEach(() => { + useAppStore.setState({ agentStatusByPaneKey: {}, nativeChatLaunchPromptByTabId: {} }) + vi.stubGlobal('IntersectionObserver', undefined) + vi.stubGlobal('fetch', mocks.fetch) + vi.stubGlobal('api', { ui: { writeClipboardImage: mocks.writeClipboardImage } }) + mocks.convertImageBlobToPng.mockImplementation(async (blob: Blob) => blob) + mocks.writeClipboardImage.mockResolvedValue(undefined) +}) + +afterEach(() => { + cleanup() + useAppStore.setState({ agentStatusByPaneKey: {}, nativeChatLaunchPromptByTabId: {} }) + vi.unstubAllGlobals() + vi.resetAllMocks() +}) + +describe('NativeChatResolvedView image menu ownership', () => { + it('refuses image capture from a portaled preview while its retained owner is hidden', async () => { + mocks.fetch.mockResolvedValue({ ok: true, blob: async () => new Blob(['original image']) }) + const view = render() + fireEvent.click(screen.getByRole('button', { name: 'View image: original.png' })) + const preview = screen.getByRole('dialog') + expect(screen.getByTestId('retained-owner').contains(preview)).toBe(false) + + view.rerender() + fireEvent.contextMenu(within(preview).getByRole('img')) + + expect(mocks.fetch).not.toHaveBeenCalled() + view.rerender() + expect(screen.queryByRole('menu')).toBeNull() + expect(mocks.writeClipboardImage).not.toHaveBeenCalled() + }) + + it('closes the image preview on the first Close click while its copy menu is open', async () => { + mocks.fetch.mockResolvedValue({ ok: true, blob: async () => new Blob(['original image']) }) + render() + fireEvent.click(screen.getByRole('button', { name: 'View image: original.png' })) + const preview = screen.getByRole('dialog', { name: 'original.png' }) + fireEvent.contextMenu(within(preview).getByRole('img')) + await screen.findByRole('menuitem', { name: 'Copy image' }) + const close = within(preview).getByRole('button', { name: 'Close' }) + + fireEvent.pointerDown(close, { button: 0 }) + fireEvent.pointerUp(close, { button: 0 }) + fireEvent.click(close) + + await waitFor(() => expect(preview).not.toBeInTheDocument()) + expect(mocks.writeClipboardImage).not.toHaveBeenCalled() + }) + + it('copies the original image through the visible bridge chat menu', async () => { + mocks.fetch.mockResolvedValue({ ok: true, blob: async () => new Blob(['original image']) }) + 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') + expect(mocks.error).not.toHaveBeenCalled() + }) + + it('removes the portaled menu and captured image when the retained bridge owner hides', async () => { + const capturedImage = Promise.withResolvers() + mocks.fetch.mockResolvedValue({ ok: true, blob: () => capturedImage.promise }) + const view = render() + const owner = screen.getByTestId('retained-owner') + const chatRoot = owner.querySelector('[data-native-chat-root]') + + fireEvent.contextMenu(screen.getByRole('img', { name: 'original.png' })) + const copyAction = await screen.findByRole('menuitem', { name: 'Copy image' }) + const portaledMenu = screen.getByRole('menu') + expect(owner.contains(portaledMenu)).toBe(false) + expect(portaledMenu.closest('[inert]')).toBeNull() + expect(mocks.fetch).toHaveBeenCalledWith('blob:original') + + // Hide without outside focus/pointer events, which can mask missing caller visibility. + view.rerender() + expect(owner.querySelector('[data-native-chat-root]')).toBe(chatRoot) + await waitFor(() => expect(portaledMenu).not.toBeInTheDocument()) + expect(screen.queryByRole('menuitem', { name: 'Copy image', hidden: true })).toBeNull() + + await act(async () => capturedImage.resolve(new Blob(['hidden image']))) + fireEvent.click(copyAction) + view.rerender() + expect(screen.queryByRole('menu')).toBeNull() + fireEvent.contextMenu(screen.getByText('Chat text')) + await screen.findByRole('menu') + expect(screen.queryByRole('menuitem', { name: 'Copy image' })).toBeNull() + expect(mocks.fetch).toHaveBeenCalledOnce() + expect(mocks.convertImageBlobToPng).not.toHaveBeenCalled() + expect(mocks.writeClipboardImage).not.toHaveBeenCalled() + expect(mocks.success).not.toHaveBeenCalled() + expect(mocks.error).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index f6eb4a55516..26355796c52 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -132,6 +132,7 @@ export function NativeChatResolvedView({ }) const contextMenu = useNativeChatContextMenu({ rootRef, + enabled: isVisible, onSwitchToTerminal, splitShortcutLabels: { right: formatShortcutLabel('terminal.splitRight', keybindings), diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx index e2b0433d73d..e8ea88d317d 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment happy-dom import { act, createElement } from 'react' +import { fireEvent, screen, within } from '@testing-library/react' import { createRoot } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' @@ -89,6 +90,63 @@ describe('NativeChatImageAttachments', () => { expect(FakeIntersectionObserver.instances[0]?.disconnect).toHaveBeenCalledOnce() }) + it('offers the full-size file of a sent image to the chat copy menu', async () => { + const container = document.createElement('div') + const root = createRoot(container) + await act(async () => { + root.render( + createElement(NativeChatImageAttachments, { + blocks: [{ type: 'image-ref' as const, path: '/repo/image.png' }], + runtimeContext: runtimeContext('wt-1') + }) + ) + await flushPromises() + }) + + expect(container.querySelector('button')?.getAttribute('data-native-chat-copy-image-src')).toBe( + 'blob:owner-1' + ) + + root.unmount() + }) + + it.each([ + 'data:image/png;base64,AA==', + 'blob:sent-original', + 'http://example.test/original.png', + 'https://example.test/original.svg' + ])( + 'offers the original displayed source %s in the thumbnail and full-size preview', + async (src) => { + const container = document.body.appendChild(document.createElement('div')) + const root = createRoot(container) + try { + await act(async () => { + root.render( + createElement(NativeChatImageAttachments, { + blocks: [{ type: 'image-ref', url: src, alt: 'Sent image' }], + runtimeContext: runtimeContext('wt-1') + }) + ) + await flushPromises() + }) + const thumbnail = within(container).getByRole('button', { name: 'View image: Sent image' }) + expect(thumbnail.getAttribute('data-native-chat-copy-image-src')).toBe(src) + fireEvent.click(thumbnail) + const preview = screen.getByRole('dialog', { name: 'Sent image' }) + expect( + within(preview).getByRole('img').getAttribute('data-native-chat-copy-image-src') + ).toBe(src) + expect(window.api.fs.readFile).not.toHaveBeenCalled() + fireEvent.click(within(preview).getByRole('button', { name: 'Close' })) + expect(screen.queryByRole('dialog')).toBeNull() + } finally { + await act(async () => root.unmount()) + container.remove() + } + } + ) + it('preserves same-image errors but retries when the runtime owner changes', async () => { const container = document.createElement('div') const root = createRoot(container) diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx index 8bd7c1645d2..e2f712c742c 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx @@ -15,6 +15,7 @@ import { releaseLocalImageSrcByKey } from '@/components/editor/useLocalImageSrc' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' +import { copyableNativeChatImageSrc, keepPreviewOpenForChatMenu } from './native-chat-image-copy' import { isNativeChatPastedImagePath } from './native-chat-image-paste' import { chatImageAccess } from '@/lib/local-file-access' @@ -119,6 +120,7 @@ function TranscriptImagePreview({ TRANSCRIPT_IMAGE_ACCESS ) const displaySrc = external && leaseActive ? source : localSrc + const copySrc = copyableNativeChatImageSrc(displaySrc) const label = block.alt?.trim() || (block.path && isNativeChatPastedImagePath(block.path) @@ -171,6 +173,7 @@ function TranscriptImagePreview({ type="button" aria-label={`${viewImageLabel}: ${label}`} title={label} + data-native-chat-copy-image-src={copySrc} onClick={() => setOpen(true)} className="flex size-full items-center justify-center overflow-hidden rounded-md border border-border bg-chat-canvas transition-colors hover:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > @@ -183,7 +186,10 @@ function TranscriptImagePreview({ /> - + {label} {translate('components.native-chat.composer.imagePreview', 'Full-size image preview')} @@ -193,6 +199,7 @@ function TranscriptImagePreview({ {label} setDialogErrorSrc(displaySrc)} className="max-h-[75vh] max-w-full object-contain" /> 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 new file mode 100644 index 00000000000..32dd7581e14 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-image-copy.ts @@ -0,0 +1,80 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { blobToBase64, convertImageBlobToPng } from '@/lib/image-blob-png' +import { isWebClientLocation } from '@/lib/web-client-location' +import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../../shared/clipboard-image' + +/** The chat context menu offers "Copy image" for elements carrying this attribute. */ +const COPY_IMAGE_SRC_SELECTOR = '[data-native-chat-copy-image-src]' + +/** Full-size source to copy for an image, or undefined when it has none. */ +export function copyableNativeChatImageSrc(src: string | undefined): string | undefined { + // The web client's image clipboard write is a no-op. + return isWebClientLocation() ? undefined : src +} + +export type NativeChatCopyImage = () => Promise + +async function readImageBlob(src: string): Promise { + const response = await fetch(src) + if (!response.ok) { + throw new Error(`Image request failed (HTTP ${response.status})`) + } + const blob = await response.blob() + if (blob.type.split(';', 1)[0] === 'text/html') { + throw new Error('The image URL returned a web page') + } + return blob +} + +/** Captures revocable sources now; remote URLs are read only when copying is selected. */ +export function readNativeChatCopyImage( + target: EventTarget | null +): NativeChatCopyImage | undefined { + const src = + target instanceof Element + ? target.closest(COPY_IMAGE_SRC_SELECTOR)?.getAttribute('data-native-chat-copy-image-src') + : undefined + if (!src) { + return undefined + } + if (/^https?:/i.test(src)) { + return () => readImageBlob(src) + } + const image = readImageBlob(src) + // Why: a read the user never copies must not surface as an unhandled rejection. + image.catch(() => {}) + return () => image +} + +/** Keeps an image preview open through clicks on the chat context menu. */ +export function keepPreviewOpenForChatMenu(event: { + target: EventTarget | null + preventDefault: () => void +}): void { + // Why: the menu portals outside the preview, so Radix counts a click on it as outside. + if (event.target instanceof Element && event.target.closest('[data-native-chat-context-menu]')) { + event.preventDefault() + } +} + +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)}`) + toast.success(translate('components.native-chat.composer.imageCopied', 'Image copied')) + } catch (error) { + toast.error( + translate('components.native-chat.composer.copyImageFailed', "Couldn't copy image"), + { + description: + error instanceof Error && error.message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR + ? translate( + 'components.native-chat.composer.copyImageTooLarge', + 'The image is too large to copy.' + ) + : undefined + } + ) + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx index 45745568b80..32dac1b4680 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx @@ -3,8 +3,10 @@ */ import React, { createRef, type ReactNode } from 'react' import { renderToStaticMarkup } from 'react-dom/server' -import { cleanup, render } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ImageBlobPng from '@/lib/image-blob-png' +import { CLIPBOARD_IMAGE_MAX_SOURCE_BYTES } from '../../../../shared/clipboard-image' import { emptyNativeChatContextMenuActions, useNativeChatContextMenu, @@ -14,6 +16,9 @@ import { type ItemProps = { onSelect?: () => void; children?: ReactNode } const items = vi.hoisted(() => ({ list: [] as ItemProps[] })) +const imageCopy = vi.hoisted(() => ({ + convertImageBlobToPng: vi.fn() +})) vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenu: ({ children }: { children?: ReactNode }) => children, @@ -37,6 +42,7 @@ vi.mock('lucide-react', () => { Clipboard: Icon, Copy: Icon, GitFork: Icon, + Image: Icon, Maximize2: Icon, MessageSquarePlus: Icon, Minimize2: Icon, @@ -68,6 +74,10 @@ vi.mock('@/components/ui/tooltip', () => { } } }) +vi.mock('@/lib/image-blob-png', async (importOriginal) => ({ + ...(await importOriginal()), + convertImageBlobToPng: imageCopy.convertImageBlobToPng +})) vi.mock('@/components/tab-bar/TabWorkspaceLayoutMenuSection', () => ({ TabWorkspaceLayoutMenuSection: () => 'Move Tab to Split' @@ -113,6 +123,66 @@ function Harness({ return menu } +function ImageHarness({ + enabled = true, + src = 'blob:full-size' +}: { + enabled?: boolean + src?: string +}) { + const rootRef = createRef() + const { menu, onContextMenuCapture } = useNativeChatContextMenu({ + rootRef, + enabled, + actions: { ...emptyNativeChatContextMenuActions, onPaste: vi.fn() } + }) + return ( +
+ +

text

+ {menu} +
+ ) +} + +function copyImageItem(): ItemProps | undefined { + return items.list.findLast((candidate) => childrenText(candidate.children) === 'Copy image') +} + +function stubClipboardImageWrite(): ReturnType { + const writeClipboardImage = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('api', { ui: { writeClipboardImage } }) + return writeClipboardImage +} + +async function rightClickImageAndCopy({ + blobFor = (src: string) => new Blob([src]), + revokeBeforeSelect = false +}: { blobFor?: (src: string) => Blob; revokeBeforeSelect?: boolean } = {}): Promise<{ + writeClipboardImage: ReturnType +}> { + const writeClipboardImage = stubClipboardImageWrite() + vi.stubGlobal( + 'fetch', + vi.fn(async (src: string) => ({ ok: true, blob: async () => blobFor(src) })) + ) + render() + fireEvent.contextMenu(screen.getByRole('img', { name: 'shot' })) + if (revokeBeforeSelect) { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) + } + await act(async () => copyImageItem()?.onSelect?.()) + return { writeClipboardImage } +} + +function pngOfText(): void { + imageCopy.convertImageBlobToPng.mockImplementation( + async (blob: Blob) => new Blob([`png:${await blob.text()}`], { type: 'image/png' }) + ) +} + describe('useNativeChatContextMenu', () => { beforeEach(() => { items.list = [] @@ -121,6 +191,141 @@ describe('useNativeChatContextMenu', () => { afterEach(() => { cleanup() vi.restoreAllMocks() + vi.unstubAllGlobals() + imageCopy.convertImageBlobToPng.mockReset() + toasts.error.mockReset() + toasts.success.mockReset() + }) + + it('copies the right-clicked image as a PNG of its full-size source', async () => { + pngOfText() + + const { writeClipboardImage } = await rightClickImageAndCopy() + + await waitFor(() => + expect(writeClipboardImage).toHaveBeenCalledWith( + `data:image/png;base64,${Buffer.from('png:blob:full-size').toString('base64')}` + ) + ) + expect(toasts.success).toHaveBeenCalledWith('Image copied') + expect(toasts.error).not.toHaveBeenCalled() + }) + + it('offers no image copy when the right-click is not on an image', () => { + render() + fireEvent.contextMenu(screen.getByText('text')) + + expect(copyImageItem()).toBeUndefined() + }) + + it('copies an image whose blob URL was revoked after the menu opened', async () => { + pngOfText() + + const { writeClipboardImage } = await rightClickImageAndCopy({ revokeBeforeSelect: true }) + + await waitFor(() => expect(writeClipboardImage).toHaveBeenCalledOnce()) + expect(toasts.error).not.toHaveBeenCalled() + }) + + it('releases the read image when the pane hides with the menu open', () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true, blob: async () => new Blob(['image']) })) + ) + const { rerender } = render() + fireEvent.contextMenu(screen.getByRole('img', { name: 'shot' })) + expect(copyImageItem()).toBeDefined() + + rerender() + // Inspect a render after the hide effect has settled. + items.list = [] + rerender() + + expect(copyImageItem()).toBeUndefined() + }) + + it.each(['http://example.test/original.png', 'https://example.test/original.svg'])( + 'reads %s only after Copy image is selected', + async (src) => { + pngOfText() + const writeClipboardImage = stubClipboardImageWrite() + const fetchImage = vi.fn().mockResolvedValue({ ok: true, blob: async () => new Blob([src]) }) + vi.stubGlobal('fetch', fetchImage) + render() + fireEvent.contextMenu(screen.getByRole('img', { name: 'shot' })) + expect(copyImageItem()).toBeDefined() + expect(fetchImage).not.toHaveBeenCalled() + + await act(async () => copyImageItem()?.onSelect?.()) + + await waitFor(() => expect(writeClipboardImage).toHaveBeenCalledOnce()) + expect(fetchImage).toHaveBeenCalledOnce() + expect(fetchImage).toHaveBeenCalledWith(src) + expect(writeClipboardImage).toHaveBeenCalledOnce() + expect(toasts.success).toHaveBeenCalledWith('Image copied') + } + ) + + it.each([ + ['HTTP failure', () => Promise.resolve({ ok: false, status: 404, blob: vi.fn() })], + ['network failure', () => Promise.reject(new TypeError('Failed to fetch'))], + [ + 'HTML response', + () => + Promise.resolve({ ok: true, blob: async () => new Blob(['page'], { type: 'text/html' }) }) + ] + ])( + 'reports one error for %s with no clipboard write or file fallback', + async (_label, response) => { + const writeClipboardImage = stubClipboardImageWrite() + const fetchImage = vi.fn(response) + vi.stubGlobal('fetch', fetchImage) + render() + fireEvent.contextMenu(screen.getByRole('img', { name: 'shot' })) + + await act(async () => copyImageItem()?.onSelect?.()) + + await waitFor(() => expect(toasts.error).toHaveBeenCalledOnce()) + expect(fetchImage).toHaveBeenCalledOnce() + expect(imageCopy.convertImageBlobToPng).not.toHaveBeenCalled() + expect(writeClipboardImage).not.toHaveBeenCalled() + expect(toasts.error).toHaveBeenCalledOnce() + expect(toasts.success).not.toHaveBeenCalled() + } + ) + + it('captures a data image before selection and keeps it when the source disappears', async () => { + pngOfText() + const writeClipboardImage = stubClipboardImageWrite() + const fetchImage = vi + .fn() + .mockResolvedValue({ ok: true, blob: async () => new Blob(['inline']) }) + vi.stubGlobal('fetch', fetchImage) + render() + fireEvent.contextMenu(screen.getByRole('img', { name: 'shot' })) + expect(fetchImage).toHaveBeenCalledOnce() + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Source gone'))) + + await act(async () => copyImageItem()?.onSelect?.()) + + await waitFor(() => expect(writeClipboardImage).toHaveBeenCalledOnce()) + expect(toasts.success).toHaveBeenCalledOnce() + }) + + it('reports an image too large to copy instead of copying nothing silently', async () => { + const actual = await vi.importActual('@/lib/image-blob-png') + imageCopy.convertImageBlobToPng.mockImplementation(actual.convertImageBlobToPng) + + const { writeClipboardImage } = await rightClickImageAndCopy({ + blobFor: () => new Blob([new Uint8Array(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1)]) + }) + + await waitFor(() => expect(toasts.error).toHaveBeenCalled()) + expect(writeClipboardImage).not.toHaveBeenCalled() + expect(toasts.success).not.toHaveBeenCalled() + expect(toasts.error).toHaveBeenCalledWith("Couldn't copy image", { + description: 'The image is too large to copy.' + }) }) it('restores the bridge switch-to-terminal action when supplied', () => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx index 5df2cf53d82..d4032248468 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx @@ -10,6 +10,7 @@ import { Clipboard, Copy, GitFork, + Image as ImageIcon, Maximize2, MessageSquarePlus, Minimize2, @@ -29,6 +30,11 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { translate } from '@/i18n/i18n' +import { + copyNativeChatImage, + readNativeChatCopyImage, + type NativeChatCopyImage +} from './native-chat-image-copy' import { isMacPlatform, nativeChatToggleShortcutLabel } from './native-chat-shortcut' import { TabWorkspaceLayoutMenuSection } from '@/components/tab-bar/TabWorkspaceLayoutMenuSection' import { NativeChatCopyOrcaSessionIdMenuItem } from './NativeChatCopyOrcaSessionIdMenuItem' @@ -38,6 +44,7 @@ type NativeChatContextMenuState = { open: boolean point: { x: number; y: number } selectedText: string + image?: NativeChatCopyImage } type UseNativeChatContextMenuArgs = { @@ -121,6 +128,7 @@ export function useNativeChatContextMenu({ selectedText: '' }) const shortcutLabel = nativeChatToggleShortcutLabel(isMacPlatform()) + const { image } = state const rememberCurrentSelection = useCallback(() => { const selectedText = getNativeChatSelectedText(rootRef.current) @@ -139,7 +147,9 @@ export function useNativeChatContextMenu({ useEffect(() => { if (!enabled) { - setState((current) => (current.open ? { ...current, open: false } : current)) + setState((current) => + current.open ? { ...current, open: false, image: undefined } : current + ) } }, [enabled]) @@ -147,15 +157,19 @@ export function useNativeChatContextMenu({ (event: React.MouseEvent) => { event.preventDefault() event.stopPropagation() + if (!enabled) { + return + } menuOpenedAtRef.current = Date.now() const selectedText = getNativeChatSelectedText(rootRef.current) || lastSelectedTextRef.current setState({ open: true, point: { x: event.clientX, y: event.clientY }, - selectedText + selectedText, + image: readNativeChatCopyImage(event.target) }) }, - [rootRef] + [enabled, rootRef] ) const setOpen = useCallback((open: boolean) => { @@ -179,11 +193,25 @@ export function useNativeChatContextMenu({ /> event.preventDefault()} + onCloseAutoFocus={(event) => { + event.preventDefault() + // Drop the read image once the menu has faded out, so a large file isn't held. + setState((prev) => ({ ...prev, image: undefined })) + }} > + {image ? ( + <> + void copyNativeChatImage(image)}> + + {translate('components.native-chat.composer.copyImage', 'Copy image')} + + + + ) : null} void window.api.ui.writeClipboardText(state.selectedText)} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ed1227bc15a..83fa52581a0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18006,6 +18006,10 @@ "viewAttachment": "View image", "imagePreview": "Full-size image preview", "imagePreviewUnavailable": "Preview unavailable", + "copyImage": "Copy image", + "copyImageFailed": "Couldn't copy image", + "copyImageTooLarge": "The image is too large to copy.", + "imageCopied": "Image copied", "workspaceAttachmentMismatch": "Files can only be attached to their source workspace.", "attachmentOwnerChanged": "This workspace changed hosts while attaching — drop the files again.", "attachmentUnreadable": "Couldn't read the dropped files.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 40eacd2614c..5cf1105e47d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17689,6 +17689,10 @@ "localAttachmentUnsupported": "Los archivos adjuntos locales no están disponibles para sesiones remotas.", "removeAttachment": "Quitar archivo adjunto", "pastedImageLabel": "Imagen pegada", + "copyImage": "Copiar imagen", + "copyImageFailed": "No se pudo copiar la imagen", + "copyImageTooLarge": "La imagen es demasiado grande para copiarla.", + "imageCopied": "Imagen copiada", "draftNotSaved": "Este borrador aún no se ha podido guardar. Orca lo sigue intentando.", "imageNotBroughtBack": "No se pudo recuperar la imagen {{name}} con este borrador. Vuelve a adjuntarla o quítala.", "pastedImageNotBroughtBack": "No se pudo recuperar esta imagen pegada con este borrador. Quítala y vuelve a pegarla si aún la necesitas.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index dbaaa188e92..7e634b6640a 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17844,6 +17844,10 @@ "viewAttachment": "Voir l'image", "imagePreview": "Aperçu de l'image en taille réelle", "imagePreviewUnavailable": "Aperçu indisponible", + "copyImage": "Copier l’image", + "copyImageFailed": "Impossible de copier l’image", + "copyImageTooLarge": "L’image est trop volumineuse pour être copiée.", + "imageCopied": "Image copiée", "workspaceAttachmentMismatch": "Les fichiers ne peuvent être joints qu'à leur espace de travail source.", "attachmentOwnerChanged": "Cet espace de travail a changé d'hôte lors de la connexion : supprimez à nouveau les fichiers.", "attachmentUnreadable": "Impossible de lire les fichiers supprimés.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 99eaf64aa7a..e9a4754b48f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17778,6 +17778,10 @@ "viewAttachment": "画像を見る", "imagePreview": "フルサイズの画像プレビュー", "imagePreviewUnavailable": "プレビューは利用できません", + "copyImage": "画像をコピー", + "copyImageFailed": "画像をコピーできませんでした", + "copyImageTooLarge": "画像が大きすぎるためコピーできません。", + "imageCopied": "画像をコピーしました", "workspaceAttachmentMismatch": "ファイルはソースワークスペースにのみ添付できます。", "attachmentOwnerChanged": "このワークスペースは接続中にホストを変更しました。ファイルを再度ドロップしてください。", "attachmentUnreadable": "ドロップされたファイルを読み取れませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c5cc00dcf38..2f656441fe7 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17778,6 +17778,10 @@ "viewAttachment": "이미지 보기", "imagePreview": "전체 크기 이미지 미리보기", "imagePreviewUnavailable": "미리보기를 사용할 수 없습니다.", + "copyImage": "이미지 복사", + "copyImageFailed": "이미지를 복사할 수 없습니다", + "copyImageTooLarge": "이미지가 너무 커서 복사할 수 없습니다.", + "imageCopied": "이미지를 복사했습니다", "workspaceAttachmentMismatch": "파일은 소스 워크스페이스에만 첨부할 수 있습니다.", "attachmentOwnerChanged": "이 워크스페이스는 첨부하는 동안 호스트를 변경했습니다. 파일을 다시 삭제하세요.", "attachmentUnreadable": "삭제된 파일을 읽을 수 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4b2aa106a26..30c5624f3fd 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17743,6 +17743,10 @@ "viewAttachment": "查看图片", "imagePreview": "全尺寸图像预览", "imagePreviewUnavailable": "预览不可用", + "copyImage": "复制图片", + "copyImageFailed": "无法复制图片", + "copyImageTooLarge": "图片太大,无法复制。", + "imageCopied": "已复制图片", "workspaceAttachmentMismatch": "文件只能附加到其源工作区。", "attachmentOwnerChanged": "此工作区在附加时更改了主机 - 再次删除文件。", "attachmentUnreadable": "无法读取删除的文件。", diff --git a/src/renderer/src/lib/image-blob-png.test.ts b/src/renderer/src/lib/image-blob-png.test.ts new file mode 100644 index 00000000000..65059c5ad88 --- /dev/null +++ b/src/renderer/src/lib/image-blob-png.test.ts @@ -0,0 +1,145 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + CLIPBOARD_IMAGE_MAX_PIXELS, + CLIPBOARD_IMAGE_MAX_SOURCE_BYTES, + CLIPBOARD_IMAGE_TOO_LARGE_ERROR +} from '../../../shared/clipboard-image' +import { convertImageBlobToPng } from './image-blob-png' + +const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]) +const WEBP_BYTES = new TextEncoder().encode('RIFF\u0000\u0000\u0000WEBPVP8 ') +const originalCanvasGetContext = Object.getOwnPropertyDescriptor( + HTMLCanvasElement.prototype, + 'getContext' +) + +function stubDecodedSize( + width: number, + height: number, + decode = vi.fn().mockResolvedValue(undefined) +) { + vi.stubGlobal( + 'Image', + class { + src = '' + naturalWidth = width + naturalHeight = height + decode = decode + } + ) + return decode +} + +function stubCanvas(png: Blob | null = new Blob([PNG_BYTES], { type: 'image/png' })) { + const dimensions = { width: 0, height: 0 } + const drawImage = vi.fn() + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + value: () => ({ drawImage }), + configurable: true + }) + vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(function ( + this: HTMLCanvasElement, + callback + ) { + dimensions.width = this.width + dimensions.height = this.height + callback(png) + }) + return { dimensions, drawImage, png } +} + +beforeEach(() => { + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:decode-owned') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + if (originalCanvasGetContext) { + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', originalCanvasGetContext) + } +}) + +describe('convertImageBlobToPng', () => { + it('validates dimensions and returns PNG signature bytes unchanged', async () => { + const decode = stubDecodedSize(800, 600) + const png = new Blob([PNG_BYTES], { type: 'image/png' }) + + await expect(convertImageBlobToPng(png)).resolves.toBe(png) + + expect(decode).toHaveBeenCalledOnce() + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:decode-owned') + }) + + it.each([ + ['a non-PNG mislabeled as PNG', () => new Blob([WEBP_BYTES], { type: 'image/png' })], + [ + 'SVG', + () => + new Blob([''], { + type: 'image/svg+xml' + }) + ] + ])( + 'decodes %s with an image element and encodes its natural dimensions', + async (_name, source) => { + stubDecodedSize(800, 600) + const { dimensions, drawImage, png } = stubCanvas() + + await expect(convertImageBlobToPng(source())).resolves.toBe(png) + + expect(dimensions.width).toBe(800) + expect(dimensions.height).toBe(600) + expect(drawImage).toHaveBeenCalledOnce() + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:decode-owned') + } + ) + + it('rejects excessive source bytes before creating a decode URL', async () => { + await expect( + convertImageBlobToPng(new Blob([new Uint8Array(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1)])) + ).rejects.toThrow(CLIPBOARD_IMAGE_TOO_LARGE_ERROR) + expect(URL.createObjectURL).not.toHaveBeenCalled() + }) + + it('rejects excessive PNG dimensions and releases its decode URL', async () => { + stubDecodedSize(CLIPBOARD_IMAGE_MAX_PIXELS + 1, 1) + + await expect( + convertImageBlobToPng(new Blob([PNG_BYTES], { type: 'image/png' })) + ).rejects.toThrow(CLIPBOARD_IMAGE_TOO_LARGE_ERROR) + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:decode-owned') + }) + + it('releases its temporary URL on a decode error', async () => { + stubDecodedSize(800, 600, vi.fn().mockRejectedValue(new Error('invalid image'))) + + await expect(convertImageBlobToPng(new Blob(['invalid']))).rejects.toThrow('invalid image') + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:decode-owned') + }) + + it('releases its temporary URL when no canvas context is available', async () => { + stubDecodedSize(800, 600) + + await expect(convertImageBlobToPng(new Blob([WEBP_BYTES]))).rejects.toThrow( + 'Clipboard image could not be decoded' + ) + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:decode-owned') + }) + + it.each([null, new Blob([new Uint8Array(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1)])])( + 'releases its temporary URL when PNG encoding fails or exceeds the byte cap', + async (png) => { + stubDecodedSize(800, 600) + stubCanvas(png) + + await expect(convertImageBlobToPng(new Blob([WEBP_BYTES]))).rejects.toThrow( + png ? CLIPBOARD_IMAGE_TOO_LARGE_ERROR : 'Clipboard image could not be encoded as PNG' + ) + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:decode-owned') + } + ) +}) diff --git a/src/renderer/src/lib/image-blob-png.ts b/src/renderer/src/lib/image-blob-png.ts new file mode 100644 index 00000000000..e18e7e209ad --- /dev/null +++ b/src/renderer/src/lib/image-blob-png.ts @@ -0,0 +1,67 @@ +import { + assertClipboardImageByteLengthWithinLimit, + assertClipboardImageDimensionsWithinLimit +} from '../../../shared/clipboard-image' + +export function blobToBase64(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const result = typeof reader.result === 'string' ? reader.result : '' + const commaIndex = result.indexOf(',') + resolve(commaIndex === -1 ? result : result.slice(commaIndex + 1)) + } + reader.onerror = () => reject(reader.error ?? new Error('Failed to read clipboard image')) + reader.readAsDataURL(blob) + }) +} + +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] + +// Why: a file's type comes from its extension, so a WebP named .png must still be converted. +async function hasPngSignature(blob: Blob): Promise { + const head = new Uint8Array(await blob.slice(0, PNG_SIGNATURE.length).arrayBuffer()) + return PNG_SIGNATURE.every((byte, index) => head[index] === byte) +} + +export async function convertImageBlobToPng(blob: Blob): Promise { + assertClipboardImageByteLengthWithinLimit(blob.size) + const url = URL.createObjectURL(blob) + try { + const image = new Image() + image.src = url + await image.decode() + const width = image.naturalWidth + const height = image.naturalHeight + assertClipboardImageDimensionsWithinLimit({ width, height }) + // Why: re-encoding a PNG costs time and can grow it past the clipboard size limit. + if (await hasPngSignature(blob)) { + return blob + } + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const context = canvas.getContext('2d') + if (!context || canvas.width <= 0 || canvas.height <= 0) { + throw new Error('Clipboard image could not be decoded') + } + context.drawImage(image, 0, 0) + return await new Promise((resolve, reject) => { + canvas.toBlob((png) => { + if (!png) { + reject(new Error('Clipboard image could not be encoded as PNG')) + return + } + try { + assertClipboardImageByteLengthWithinLimit(png.size) + } catch (error) { + reject(error) + return + } + resolve(png) + }, 'image/png') + }) + } finally { + URL.revokeObjectURL(url) + } +} diff --git a/src/renderer/src/web/preload-api/web-clipboard-api.ts b/src/renderer/src/web/preload-api/web-clipboard-api.ts index d94bc63ec05..8d270d12691 100644 --- a/src/renderer/src/web/preload-api/web-clipboard-api.ts +++ b/src/renderer/src/web/preload-api/web-clipboard-api.ts @@ -10,6 +10,7 @@ import { type ClipboardImageThumbnail } from '../../../../shared/clipboard-image' import { assertClipboardTextWriteWithinLimitWithYield } from '../../../../shared/clipboard-text' +import { blobToBase64, convertImageBlobToPng } from '@/lib/image-blob-png' import { copyClipboardTextViaExecCommand } from '../web-clipboard-copy-fallback' import { callRuntimeEnvelope, callRuntimeResult } from './web-runtime-calls' @@ -25,56 +26,10 @@ export const CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS = 256 * 1024 export const CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS = 30_000 -export function blobToBase64(blob: Blob): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => { - const result = typeof reader.result === 'string' ? reader.result : '' - const commaIndex = result.indexOf(',') - resolve(commaIndex === -1 ? result : result.slice(commaIndex + 1)) - } - reader.onerror = () => reject(reader.error ?? new Error('Failed to read clipboard image')) - reader.readAsDataURL(blob) - }) -} - export function assertClipboardImageBlobWithinLimit(blob: Blob): void { assertClipboardImageByteLengthWithinLimit(blob.size) } -export async function convertImageBlobToPng(blob: Blob): Promise { - assertClipboardImageBlobWithinLimit(blob) - const bitmap = await createImageBitmap(blob) - try { - assertClipboardImageDimensionsWithinLimit(bitmap) - const canvas = document.createElement('canvas') - canvas.width = bitmap.width - canvas.height = bitmap.height - const context = canvas.getContext('2d') - if (!context || canvas.width <= 0 || canvas.height <= 0) { - throw new Error('Clipboard image could not be decoded') - } - context.drawImage(bitmap, 0, 0) - return await new Promise((resolve, reject) => { - canvas.toBlob((png) => { - if (!png) { - reject(new Error('Clipboard image could not be encoded as PNG')) - return - } - try { - assertClipboardImageBlobWithinLimit(png) - } catch (error) { - reject(error) - return - } - resolve(png) - }, 'image/png') - }) - } finally { - bitmap.close() - } -} - async function readClipboardImageBlob(): Promise { const clipboard = navigator.clipboard as | (Clipboard & { read?: () => Promise }) diff --git a/src/renderer/src/web/web-preload-api-clipboard.test.ts b/src/renderer/src/web/web-preload-api-clipboard.test.ts index f5a6f76f8a0..ac0a8e3402f 100644 --- a/src/renderer/src/web/web-preload-api-clipboard.test.ts +++ b/src/renderer/src/web/web-preload-api-clipboard.test.ts @@ -604,7 +604,7 @@ describe('web UI preload API', () => { it('rejects oversized decoded clipboard images before canvas conversion', async () => { const runtimeCalls: { method: string; params: unknown }[] = [] - const close = vi.fn() + const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined) const readAsDataURL = vi.fn(() => { throw new Error('FileReader should not receive oversized decoded image data') }) @@ -630,12 +630,13 @@ describe('web UI preload API', () => { const { MAX_CLIPBOARD_IMAGE_PIXELS } = await import('./preload-api/web-clipboard-api') installClipboardImageBlob(new Blob(['small'], { type: 'image/jpeg' })) vi.stubGlobal( - 'createImageBitmap', - vi.fn().mockResolvedValue({ - close, - height: 1, - width: MAX_CLIPBOARD_IMAGE_PIXELS + 1 - }) + 'Image', + class { + src = '' + naturalHeight = 1 + naturalWidth = MAX_CLIPBOARD_IMAGE_PIXELS + 1 + decode = vi.fn().mockResolvedValue(undefined) + } ) vi.stubGlobal( 'FileReader', @@ -648,7 +649,8 @@ describe('web UI preload API', () => { await expect(globals.window.api.ui.saveClipboardImageAsTempFile()).rejects.toThrow( 'Clipboard image is too large' ) - expect(close).toHaveBeenCalledTimes(1) + expect(revokeObjectURL).toHaveBeenCalledTimes(1) + revokeObjectURL.mockRestore() expect(readAsDataURL).not.toHaveBeenCalled() expect(runtimeCalls).toEqual([]) }) diff --git a/tests/e2e/native-chat-copy-image.spec.ts b/tests/e2e/native-chat-copy-image.spec.ts new file mode 100644 index 00000000000..4e3ddcfc465 --- /dev/null +++ b/tests/e2e/native-chat-copy-image.spec.ts @@ -0,0 +1,113 @@ +import { randomUUID } from 'node:crypto' +import { rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal' + +test('Copy image puts the full-size image on the clipboard and keeps the preview open', async ({ + orcaPage, + electronApp, + testRepoPath +}) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const descriptor = await waitForActivePaneHookDescriptor(orcaPage) + // Why two sizes: the chip previews the pasted image while the copy must read the saved file, + // so an 800x600 copy proves the file was read, not the 64x64 preview. + const [png, pastedPng] = await electronApp.evaluate(({ nativeImage }) => + [800, 64].map((width) => { + const height = (width * 3) / 4 + return nativeImage + .createFromBitmap(Buffer.alloc(width * height * 4, 200), { width, height }) + .toPNG() + .toString('base64') + }) + ) + // Why the repo: previews only read files under allowed roots; the test output dir isn't one. + const imageName = `copy-image-proof-${randomUUID()}.png` + const imagePath = path.join(testRepoPath, imageName) + await writeFile(imagePath, Buffer.from(png, 'base64')) + try { + // Substitute only the clipboard IPC; never touch the user's system clipboard. + await electronApp.evaluate( + ({ ipcMain, nativeImage }, { imagePath }) => { + ipcMain.removeHandler('clipboard:saveImageAsTempFile') + ipcMain.handle('clipboard:saveImageAsTempFile', () => imagePath) + ipcMain.removeHandler('clipboard:writeImage') + ipcMain.handle('clipboard:writeImage', (_event, dataUrl: string) => { + const { width, height } = nativeImage.createFromDataURL(dataUrl).getSize() + process.env.ORCA_E2E_COPIED_IMAGE_SIZE = `${width}x${height}` + }) + }, + { imagePath } + ) + const takeCopiedImageSize = (): Promise => + electronApp.evaluate(() => { + const size = process.env.ORCA_E2E_COPIED_IMAGE_SIZE ?? null + delete process.env.ORCA_E2E_COPIED_IMAGE_SIZE + return size + }) + await orcaPage.evaluate(async ({ paneKey, worktreeId }) => { + const settings = await window.api.settings.set({ experimentalNativeChat: true }) + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + store.setState({ settings }) + const state = store.getState() + state.setAgentStatus( + paneKey, + { state: 'done', agentType: 'omp', prompt: '' }, + 'OMP', + undefined, + { worktreeId } + ) + const [tabId] = paneKey.split(':') + const tab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.contentType === 'terminal' && candidate.entityId === tabId + ) + if (!tab) { + throw new Error('Terminal tab unavailable') + } + state.toggleTabViewMode(tab.id) + }, descriptor) + const composer = orcaPage.getByRole('textbox', { name: 'Send a message…', exact: true }) + await expect(composer).toBeVisible() + await composer.evaluate((element, png) => { + const data = new DataTransfer() + const bytes = Uint8Array.from(atob(png), (character) => character.charCodeAt(0)) + data.items.add(new File([bytes], 'pasted.png', { type: 'image/png' })) + element.dispatchEvent( + new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data }) + ) + }, pastedPng) + const thumbnail = orcaPage.getByRole('button', { name: `View image: ${imageName}` }) + await expect(thumbnail).toHaveAttribute('data-native-chat-copy-image-src', /^blob:/) + const copyImage = orcaPage.getByRole('menuitem', { name: 'Copy image' }) + + await thumbnail.click({ button: 'right' }) + await copyImage.click() + await expect.poll(takeCopiedImageSize).toBe('800x600') + + await thumbnail.click() + const preview = orcaPage.getByRole('dialog') + const fullSize = preview.getByRole('img', { name: imageName }) + await fullSize.click({ button: 'right' }) + await copyImage.click() + await expect.poll(takeCopiedImageSize).toBe('800x600') + // Why: the preview's dismissal fires on that click, so once the menu has fully left the page + // a dismissed preview would already read data-state="closed". + await expect(orcaPage.getByRole('menu')).toHaveCount(0) + await expect(preview).toHaveAttribute('data-state', 'open') + + await fullSize.click({ button: 'right' }) + await expect(copyImage).toBeVisible() + await preview.getByRole('button', { name: 'Close' }).click() + await expect(preview).toHaveCount(0) + } finally { + await rm(imagePath, { force: true }) + } +})