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)} />