diff --git a/apps/desktop/src/app/chat/composer/text-utils.test.ts b/apps/desktop/src/app/chat/composer/text-utils.test.ts index 998dc8b33..5ef677f4d 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.test.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { detectTrigger } from './text-utils' +import { blobDedupeKey, detectTrigger, extractClipboardImageBlobs } from './text-utils' describe('detectTrigger', () => { it('detects a bare slash trigger with an empty query', () => { @@ -23,3 +23,55 @@ describe('detectTrigger', () => { expect(detectTrigger('hello there')).toBeNull() }) }) + +describe('extractClipboardImageBlobs', () => { + it('dedupes the same image exposed on both items and files', () => { + const image = new File([new Uint8Array([1, 2, 3])], 'paste.png', { + type: 'image/png', + lastModified: 1_700_000_000_000 + }) + + const clipboard = { + files: { + length: 1, + item: (index: number) => (index === 0 ? image : null) + }, + getData: () => '', + items: [ + { + kind: 'file', + type: 'image/png', + getAsFile: () => image + } + ] + } as unknown as DataTransfer + + expect(extractClipboardImageBlobs(clipboard)).toEqual([image]) + }) + + it('falls back to files when items has no image', () => { + const image = new File([new Uint8Array([4, 5])], 'shot.jpg', { + type: 'image/jpeg', + lastModified: 1_700_000_000_001 + }) + + const clipboard = { + files: { + length: 1, + item: (index: number) => (index === 0 ? image : null) + }, + getData: () => '', + items: [] + } as unknown as DataTransfer + + expect(extractClipboardImageBlobs(clipboard)).toEqual([image]) + }) +}) + +describe('blobDedupeKey', () => { + it('uses file metadata for File blobs', () => { + const file = new File([], 'a.png', { type: 'image/png', lastModified: 42 }) + + expect(blobDedupeKey(file)).toBe('file:a.png:0:image/png:42') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index 5725883d8..e9a8fb6aa 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -8,16 +8,31 @@ export interface TriggerState { const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/ +/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */ +export function blobDedupeKey(blob: Blob): string { + if (blob instanceof File) { + return `file:${blob.name}:${blob.size}:${blob.type}:${blob.lastModified}` + } + + return `blob:${blob.size}:${blob.type}` +} + export function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] { const blobs: Blob[] = [] - const seen = new Set() + const seen = new Set() const push = (blob: Blob | null) => { - if (!blob || blob.size === 0 || seen.has(blob)) { + if (!blob || blob.size === 0) { return } - seen.add(blob) + const key = blobDedupeKey(blob) + + if (seen.has(key)) { + return + } + + seen.add(key) blobs.push(blob) } @@ -29,7 +44,8 @@ export function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] { } } - if (clipboard.files?.length) { + // Chromium/Electron expose the same pasted image on both `items` and `files`. + if (blobs.length === 0 && clipboard.files?.length) { for (let i = 0; i < clipboard.files.length; i += 1) { const file = clipboard.files.item(i)