From c711146ad4cd23b3137bf8a262be2e3f3c4a2162 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 3 Jun 2026 10:27:47 -0500 Subject: [PATCH] fix(desktop): dedupe clipboard image paste Chromium exposes the same pasted image on both DataTransfer.items and .files as distinct Blob objects, which attached twice. Prefer items and skip the files mirror when items already yielded images. --- .../src/app/chat/composer/text-utils.test.ts | 54 ++++++++++++++++++- .../src/app/chat/composer/text-utils.ts | 24 +++++++-- 2 files changed, 73 insertions(+), 5 deletions(-) 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)