diff --git a/apps/app/src/components/ui/description-image-dropzone.tsx b/apps/app/src/components/ui/description-image-dropzone.tsx index df685082..4d9df8a2 100644 --- a/apps/app/src/components/ui/description-image-dropzone.tsx +++ b/apps/app/src/components/ui/description-image-dropzone.tsx @@ -268,6 +268,52 @@ export function DescriptionImageDropZone({ [images, onImagesChange] ); + // Handle paste events to detect and process images from clipboard + // Works across all OS (Windows, Linux, macOS) + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + if (disabled || isProcessing) return; + + const clipboardItems = e.clipboardData?.items; + if (!clipboardItems) return; + + const imageFiles: File[] = []; + + // Iterate through clipboard items to find images + for (let i = 0; i < clipboardItems.length; i++) { + const item = clipboardItems[i]; + + // Check if the item is an image + if (item.type.startsWith("image/")) { + const file = item.getAsFile(); + if (file) { + // Generate a filename for pasted images since they don't have one + const extension = item.type.split("/")[1] || "png"; + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const renamedFile = new File( + [file], + `pasted-image-${timestamp}.${extension}`, + { type: file.type } + ); + imageFiles.push(renamedFile); + } + } + } + + // If we found images, process them and prevent default paste behavior + if (imageFiles.length > 0) { + e.preventDefault(); + + // Create a FileList-like object from the array + const dataTransfer = new DataTransfer(); + imageFiles.forEach((file) => dataTransfer.items.add(file)); + processFiles(dataTransfer.files); + } + // If no images found, let the default paste behavior happen (paste text) + }, + [disabled, isProcessing, processFiles] + ); + return (
{/* Hidden file input */} @@ -313,6 +359,7 @@ export function DescriptionImageDropZone({ placeholder={placeholder} value={value} onChange={(e) => onChange(e.target.value)} + onPaste={handlePaste} disabled={disabled} autoFocus={autoFocus} aria-invalid={error} @@ -326,7 +373,7 @@ export function DescriptionImageDropZone({ {/* Hint text */}

- Drag and drop images here or{" "} + Paste, drag and drop images, or{" "}

{/* Remove button */} - {!disabled && ( + {!disabled && image.id && ( + {image.id && ( + + )} ))} @@ -729,7 +800,8 @@ export function AgentView() { /> {selectedImages.length > 0 && !isDragOver && (
- {selectedImages.length} image{selectedImages.length > 1 ? "s" : ""} + {selectedImages.length} image + {selectedImages.length > 1 ? "s" : ""}
)} {isDragOver && ( @@ -748,7 +820,8 @@ export function AgentView() { disabled={isProcessing || !isConnected} className={cn( "h-11 w-11 rounded-xl border-border", - showImageDropZone && "bg-primary/10 text-primary border-primary/30", + showImageDropZone && + "bg-primary/10 text-primary border-primary/30", selectedImages.length > 0 && "border-primary/30 text-primary" )} title="Attach images" @@ -773,7 +846,11 @@ export function AgentView() { {/* Keyboard hint */}

- Press Enter to send + Press{" "} + + Enter + {" "} + to send

)} diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts index 3c2aeaa4..6992dc2d 100644 --- a/apps/app/src/store/app-store.ts +++ b/apps/app/src/store/app-store.ts @@ -210,11 +210,11 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { }; export interface ImageAttachment { - id: string; + id?: string; // Optional - may not be present in messages loaded from server data: string; // base64 encoded image data mimeType: string; // e.g., "image/png", "image/jpeg" filename: string; - size: number; // file size in bytes + size?: number; // file size in bytes - optional for messages from server } export interface ChatMessage { diff --git a/apps/app/src/types/electron.d.ts b/apps/app/src/types/electron.d.ts index 9f112568..5eb152d0 100644 --- a/apps/app/src/types/electron.d.ts +++ b/apps/app/src/types/electron.d.ts @@ -3,11 +3,11 @@ */ export interface ImageAttachment { - id: string; + id?: string; // Optional - may not be present in messages loaded from server data: string; // base64 encoded image data mimeType: string; // e.g., "image/png", "image/jpeg" filename: string; - size: number; // file size in bytes + size?: number; // file size in bytes - optional for messages from server } export interface Message { diff --git a/apps/app/tests/utils/files/drag-drop.ts b/apps/app/tests/utils/files/drag-drop.ts index 1a0c0d4a..a3828c12 100644 --- a/apps/app/tests/utils/files/drag-drop.ts +++ b/apps/app/tests/utils/files/drag-drop.ts @@ -36,3 +36,47 @@ export async function simulateFileDrop( { selector: targetSelector, content: fileContent, name: fileName, mime: mimeType } ); } + +/** + * Simulate pasting an image from clipboard onto an element + * Works across all OS (Windows, Linux, macOS) + */ +export async function simulateImagePaste( + page: Page, + targetSelector: string, + imageBase64: string, + mimeType: string = "image/png" +): Promise { + await page.evaluate( + ({ selector, base64, mime }) => { + const target = document.querySelector(selector); + if (!target) throw new Error(`Element not found: ${selector}`); + + // Convert base64 to Blob + const byteCharacters = atob(base64); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const blob = new Blob([byteArray], { type: mime }); + + // Create a File from Blob + const file = new File([blob], "pasted-image.png", { type: mime }); + + // Create a DataTransfer with clipboard items + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + + // Create ClipboardEvent with the image data + const clipboardEvent = new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + clipboardData: dataTransfer, + }); + + target.dispatchEvent(clipboardEvent); + }, + { selector: targetSelector, base64: imageBase64, mime: mimeType } + ); +}