Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 156 additions & 1 deletion apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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<string, number>()
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<string, number>()
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<string, number>()
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<string, number>()
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<string, number>()
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<typeof vi.fn>) => {
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)
})
})
63 changes: 61 additions & 2 deletions apps/desktop/src/app/chat/hooks/use-composer-actions.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string> {
// 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<string, number>, 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<string, number>, key: string): void {
seen.delete(key)
}

export function isImagePath(filePath: string): boolean {
return IMAGE_EXTENSION_PATTERN.test(filePath)
}
Expand Down Expand Up @@ -295,6 +330,8 @@ export function useComposerActions({
[scope]
)

const recentImageBlobPastesRef = useRef<Map<string, number>>(new Map())

const addTextToDraft = useCallback((text: string) => {
requestComposerInsert(text, { mode: 'block' })
}, [])
Expand Down Expand Up @@ -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)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key is remembered before saveImageBuffer, but neither the no-path return nor the catch clears it. A transient save failure therefore makes an immediate retry return true without attaching anything; remove the key on every save failure and add a retry regression test.

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
Expand Down