diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts index 76ab53ef9505..e46a9a42ece9 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts @@ -1,3 +1,4 @@ +import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { $connection } from '@/store/session' @@ -6,8 +7,12 @@ import { attachmentPreviewDataUrl, type DroppedFile, extractDroppedFiles, + forgetRecentImageBlobPaste, HERMES_PATHS_MIME, - partitionDroppedFiles + imageBlobDedupeKey, + partitionDroppedFiles, + rememberRecentImageBlobPaste, + useComposerActions } from './use-composer-actions' // A Finder/Explorer drop carries a native File handle; an in-app drag (project @@ -244,3 +249,153 @@ describe('attachmentPreviewDataUrl', () => { await expect(attachmentPreviewDataUrl('/home/gateway/shot.png')).resolves.toBe(REMOTE_PREVIEW) }) }) + +describe('recent image paste dedupe', () => { + it('drops a near-simultaneous byte-identical pasted image', () => { + const seen = new Map() + const key = 'shot' + + expect(rememberRecentImageBlobPaste(seen, key, 1000)).toBe(true) + expect(rememberRecentImageBlobPaste(seen, key, 1200)).toBe(false) + }) + + it('allows the same image again after the short dedupe window', () => { + const seen = new Map() + const key = 'shot' + + expect(rememberRecentImageBlobPaste(seen, key, 1000)).toBe(true) + expect(rememberRecentImageBlobPaste(seen, key, 2601)).toBe(true) + }) + + it('keeps distinct same-size images even when their File metadata matches', async () => { + const seen = new Map() + const a = new File([new Uint8Array([1, 2, 3])], 'paste.png', { type: 'image/png', lastModified: 1 }) + const b = new File([new Uint8Array([1, 2, 4])], 'paste.png', { type: 'image/png', lastModified: 1 }) + const aKey = await imageBlobDedupeKey(a, new Uint8Array([1, 2, 3])) + const bKey = await imageBlobDedupeKey(b, new Uint8Array([1, 2, 4])) + + expect(aKey).not.toBe(bKey) + expect(rememberRecentImageBlobPaste(seen, aKey, 1000)).toBe(true) + expect(rememberRecentImageBlobPaste(seen, bKey, 1200)).toBe(true) + }) + + it('dedupes byte-identical images across paste callbacks when File metadata differs', async () => { + const seen = new Map() + const data = new Uint8Array([1, 2, 3, 4]) + const file = new File([data], 'Screenshot 1.png', { type: 'image/png', lastModified: 1 }) + const mirroredBlob = new File([data], 'Screenshot 2.png', { type: 'image/png', lastModified: 2 }) + const fileKey = await imageBlobDedupeKey(file, data) + const mirroredKey = await imageBlobDedupeKey(mirroredBlob, data) + + expect(fileKey).toBe(mirroredKey) + expect(fileKey).toContain('sha256:') + expect(rememberRecentImageBlobPaste(seen, fileKey, 1000)).toBe(true) + expect(rememberRecentImageBlobPaste(seen, mirroredKey, 1200)).toBe(false) + }) + + it('allows retrying the same pasted image after a save failure clears its key', () => { + const seen = new Map() + const key = 'shot' + + expect(rememberRecentImageBlobPaste(seen, key, 1000)).toBe(true) + forgetRecentImageBlobPaste(seen, key) + expect(rememberRecentImageBlobPaste(seen, key, 1200)).toBe(true) + }) +}) + +describe('image paste persistence', () => { + afterEach(() => { + cleanup() + Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: undefined }) + }) + + const renderActions = (saveImageBuffer: ReturnType) => { + const add = vi.fn() + + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { + readFileDataUrl: vi.fn(async () => 'data:image/png;base64,cHJldmlldw=='), + saveImageBuffer + } + }) + + const hook = renderHook(() => + useComposerActions({ + activeSessionId: null, + currentCwd: '', + requestGateway: vi.fn(async () => undefined) as never, + scope: { + add, + remove: vi.fn(() => null), + target: 'test' + } + }) + ) + + return { add, ...hook } + } + + it('retries the same bytes immediately after a save returns no path', async () => { + const saveImageBuffer = vi.fn().mockResolvedValueOnce(undefined).mockResolvedValueOnce('/tmp/retried-image.png') + const blob = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' }) + const { add, result } = renderActions(saveImageBuffer) + + await act(async () => { + await expect(result.current.attachImageBlob(blob)).resolves.toBe(false) + await expect(result.current.attachImageBlob(blob)).resolves.toBe(true) + }) + + expect(saveImageBuffer).toHaveBeenCalledTimes(2) + expect(add).toHaveBeenCalledWith(expect.objectContaining({ path: '/tmp/retried-image.png' })) + }) + + it('retries the same bytes immediately after a save throws', async () => { + const saveImageBuffer = vi + .fn() + .mockRejectedValueOnce(new Error('disk unavailable')) + .mockResolvedValueOnce('/tmp/retried-after-error.png') + + const blob = new Blob([new Uint8Array([4, 5, 6])], { type: 'image/png' }) + const { add, result } = renderActions(saveImageBuffer) + + await act(async () => { + await expect(result.current.attachImageBlob(blob)).resolves.toBe(false) + await expect(result.current.attachImageBlob(blob)).resolves.toBe(true) + }) + + expect(saveImageBuffer).toHaveBeenCalledTimes(2) + expect(add).toHaveBeenCalledWith(expect.objectContaining({ path: '/tmp/retried-after-error.png' })) + }) + + it('persists same-size images when their bytes differ', async () => { + const saveImageBuffer = vi + .fn() + .mockResolvedValueOnce('/tmp/first-image.png') + .mockResolvedValueOnce('/tmp/second-image.png') + + const first = new Blob([new Uint8Array([7, 8, 9])], { type: 'image/png' }) + const second = new Blob([new Uint8Array([7, 8, 10])], { type: 'image/png' }) + const { result } = renderActions(saveImageBuffer) + + await act(async () => { + await expect(result.current.attachImageBlob(first)).resolves.toBe(true) + await expect(result.current.attachImageBlob(second)).resolves.toBe(true) + }) + + expect(saveImageBuffer).toHaveBeenCalledTimes(2) + }) + + it('persists byte-identical near-simultaneous pastes once', async () => { + const saveImageBuffer = vi.fn(async () => '/tmp/only-image.png') + const blob = new Blob([new Uint8Array([11, 12, 13])], { type: 'image/png' }) + const { result } = renderActions(saveImageBuffer) + + await act(async () => { + await expect(result.current.attachImageBlob(blob)).resolves.toBe(true) + await expect(result.current.attachImageBlob(blob)).resolves.toBe(true) + }) + + expect(saveImageBuffer).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index 3c31f4067c7f..4e7ab549d9ad 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import { requestComposerFocus, requestComposerInsert, requestComposerInsertRefs } from '@/app/chat/composer/focus' import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs' @@ -36,6 +36,41 @@ function blobExtension(blob: Blob): string { return BLOB_MIME_EXTENSION[mime] || '.png' } +const RECENT_IMAGE_PASTE_DEDUPE_MS = 1500 + +export async function imageBlobDedupeKey(blob: Blob, data: Uint8Array): Promise { + // The composer already collapses mirrored items/files inside one DataTransfer. + // This content key spans separate attachImageBlob calls, where the same macOS + // screenshot can arrive with different File metadata. + const digestInput = Uint8Array.from(data).buffer + const digest = await crypto.subtle.digest('SHA-256', digestInput) + const hash = Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('') + + return [blob.size, `sha256:${hash}`].join('|') +} + +export function rememberRecentImageBlobPaste(seen: Map, key: string, now = Date.now()): boolean { + for (const [seenKey, seenAt] of seen) { + if (now - seenAt > RECENT_IMAGE_PASTE_DEDUPE_MS) { + seen.delete(seenKey) + } + } + + if (seen.has(key)) { + seen.set(key, now) + + return false + } + + seen.set(key, now) + + return true +} + +export function forgetRecentImageBlobPaste(seen: Map, key: string): void { + seen.delete(key) +} + export function isImagePath(filePath: string): boolean { return IMAGE_EXTENSION_PATTERN.test(filePath) } @@ -295,6 +330,8 @@ export function useComposerActions({ [scope] ) + const recentImageBlobPastesRef = useRef>(new Map()) + const addTextToDraft = useCallback((text: string) => { requestComposerInsert(text, { mode: 'block' }) }, []) @@ -444,19 +481,41 @@ export function useComposerActions({ return false } + let dedupeKey: string | undefined + try { const buffer = await blob.arrayBuffer() const data = new Uint8Array(buffer) + dedupeKey = await imageBlobDedupeKey(blob, data) + + // macOS/Electron can fire the same Cmd+V screenshot through multiple + // clipboard paths/events. Drop only near-simultaneous byte-identical + // image blobs so a pasted screenshot attaches once. + if (!rememberRecentImageBlobPaste(recentImageBlobPastesRef.current, dedupeKey)) { + return true + } + const savedPath = await window.hermesDesktop?.saveImageBuffer(data, blobExtension(blob)) if (!savedPath) { + forgetRecentImageBlobPaste(recentImageBlobPastesRef.current, dedupeKey) notify({ kind: 'error', title: copy.imageAttach, message: copy.imageWriteFailed }) return false } - return attachImagePath(savedPath) + const attached = await attachImagePath(savedPath) + + if (!attached) { + forgetRecentImageBlobPaste(recentImageBlobPastesRef.current, dedupeKey) + } + + return attached } catch (err) { + if (dedupeKey) { + forgetRecentImageBlobPaste(recentImageBlobPastesRef.current, dedupeKey) + } + notifyError(err, copy.imageAttachFailed) return false