From 0e9d10732a0f4a271239a15cdb13472a695e3160 Mon Sep 17 00:00:00 2001 From: Greg Burch Date: Wed, 1 Oct 2025 12:17:51 -0400 Subject: [PATCH] Allow image pasting into task field (#883) * **Changes** - Made `ImageUploadSection` ref-aware with an `addFiles` imperative handle and auto-expansion when images appear, enabling non-UI triggers to reuse its validation/upload flow (frontend/src/components/ui/ImageUploadSection.tsx:36-348). - Captured clipboard image blobs in the shared textarea component and exposed them via a new `onPasteFiles` hook for upstream consumers (frontend/src/components/ui/file-search-textarea.tsx:111-137). - Task creation dialog now routes pasted images straight into the uploader through the new ref, keeping the description markdown in sync (frontend/src/components/dialogs/tasks/TaskFormDialog.tsx:102-357). - Follow-up editor keeps the image panel mounted but hidden, reveals it on paste, and forwards the new prop through its wrapper so task attempts accept clipboard images (frontend/src/components/tasks/TaskFollowUpSection.tsx:89-325; frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx:13-47). **Tests** - `npm run check` (frontend) - `pnpm run check` (timed out after backend cargo check kicked off; see log) **Next Steps** 1. Re-run `pnpm run check` or `npm run backend:check` when you can let Cargo finish to confirm backend continues to build. 2. Manually paste a few image formats/sizes into the task form and follow-up editor to confirm UX and markdown output look right. * Update frontend/src/components/ui/ImageUploadSection.tsx Co-authored-by: Gabriel Gordon-Hall * fmt --------- Co-authored-by: Greg Burch Co-authored-by: Gabriel Gordon-Hall Co-authored-by: Gabriel Gordon-Hall --- .../dialogs/tasks/TaskFormDialog.tsx | 15 +- .../components/tasks/TaskFollowUpSection.tsx | 57 +- .../tasks/follow-up/FollowUpEditorCard.tsx | 3 + .../src/components/ui/ImageUploadSection.tsx | 552 ++++++++++-------- .../components/ui/file-search-textarea.tsx | 31 + 5 files changed, 385 insertions(+), 273 deletions(-) diff --git a/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx b/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx index f0a9ab56..81947007 100644 --- a/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx +++ b/frontend/src/components/dialogs/tasks/TaskFormDialog.tsx @@ -1,7 +1,10 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { Globe2, Settings2, ChevronRight } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { ImageUploadSection } from '@/components/ui/ImageUploadSection'; +import { + ImageUploadSection, + type ImageUploadSectionHandle, +} from '@/components/ui/ImageUploadSection'; import { Dialog, DialogContent, @@ -82,6 +85,7 @@ export const TaskFormDialog = NiceModal.create( useState(null); const [quickstartExpanded, setQuickstartExpanded] = useState(false); + const imageUploadRef = useRef(null); const isEditMode = Boolean(task); @@ -290,6 +294,11 @@ export const TaskFormDialog = NiceModal.create( ); }, []); + const handlePasteImages = useCallback((files: File[]) => { + if (files.length === 0) return; + void imageUploadRef.current?.addFiles(files); + }, []); + const handleSubmit = useCallback(async () => { if (!title.trim() || !projectId) return; @@ -492,10 +501,12 @@ export const TaskFormDialog = NiceModal.create( isEditMode ? handleSubmit : handleCreateAndStart } onCommandShiftEnter={handleSubmit} + onPasteFiles={handlePasteImages} /> (null); + + const handlePasteImages = useCallback((files: File[]) => { + if (files.length === 0) return; + setShowImageUpload(true); + void imageUploadRef.current?.addFiles(files); + }, []); // Variant selection (with keyboard cycling) const { selectedVariant, setSelectedVariant, currentProfile } = @@ -288,25 +298,29 @@ export function TaskFollowUpSection({ )}
- {showImageUpload && ( -
- imagesApi.uploadForTask(task.id, file)} - onDelete={imagesApi.delete} - onImageUploaded={(image) => { - handleImageUploaded(image); - setFollowUpMessage((prev) => - appendImageMarkdown(prev, image) - ); - }} - disabled={!isEditable} - collapsible={false} - defaultExpanded={true} - /> -
- )} +
+ imagesApi.uploadForTask(task.id, file)} + onDelete={imagesApi.delete} + onImageUploaded={(image) => { + handleImageUploaded(image); + setFollowUpMessage((prev) => + appendImageMarkdown(prev, image) + ); + }} + disabled={!isEditable} + collapsible={false} + defaultExpanded={true} + /> +
{/* Review comments preview */} {reviewMarkdown && ( @@ -352,6 +366,7 @@ export function TaskFollowUpSection({ showLoadingOverlay={isUnqueuing || !isDraftLoaded} onCommandEnter={onSendFollowUp} onCommandShiftEnter={onSendFollowUp} + onPasteFiles={handlePasteImages} /> ) => void; onCommandShiftEnter?: (e: React.KeyboardEvent) => void; + onPasteFiles?: (files: File[]) => void; textareaClassName?: string; }; @@ -25,6 +26,7 @@ export function FollowUpEditorCard({ showLoadingOverlay, onCommandEnter, onCommandShiftEnter, + onPasteFiles, textareaClassName, }: Props) { const { projectId } = useProject(); @@ -42,6 +44,7 @@ export function FollowUpEditorCard({ maxRows={6} onCommandEnter={onCommandEnter} onCommandShiftEnter={onCommandShiftEnter} + onPasteFiles={onPasteFiles} /> {showLoadingOverlay && (
diff --git a/frontend/src/components/ui/ImageUploadSection.tsx b/frontend/src/components/ui/ImageUploadSection.tsx index b23b2ebf..897b5b80 100644 --- a/frontend/src/components/ui/ImageUploadSection.tsx +++ b/frontend/src/components/ui/ImageUploadSection.tsx @@ -1,4 +1,11 @@ -import { useState, useCallback, useRef } from 'react'; +import { + useState, + useCallback, + useRef, + useImperativeHandle, + useEffect, + forwardRef, +} from 'react'; import { X, Image as ImageIcon, @@ -26,279 +33,324 @@ interface ImageUploadSectionProps { className?: string; } -export function ImageUploadSection({ - images, - onImagesChange, - onUpload, - onDelete, - onImageUploaded, - isUploading = false, - disabled = false, - readOnly = false, - collapsible = true, - defaultExpanded = false, - className, -}: ImageUploadSectionProps) { - const [isExpanded, setIsExpanded] = useState( - defaultExpanded || images.length > 0 - ); - const [isDragging, setIsDragging] = useState(false); - const [uploadingFiles, setUploadingFiles] = useState>(new Set()); - const [errorMessage, setErrorMessage] = useState(null); - const fileInputRef = useRef(null); +export interface ImageUploadSectionHandle { + addFiles: (files: FileList | File[] | null) => Promise; +} - const handleFileSelect = useCallback( - async (files: FileList | null) => { - if (!files || disabled) return; +const MAX_SIZE_BYTES = 20 * 1024 * 1024; // 20MB +const VALID_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/webp', + 'image/bmp', + 'image/svg+xml', +]; - setErrorMessage(null); +export const ImageUploadSection = forwardRef< + ImageUploadSectionHandle, + ImageUploadSectionProps +>( + ( + { + images, + onImagesChange, + onUpload, + onDelete, + onImageUploaded, + isUploading = false, + disabled = false, + readOnly = false, + collapsible = true, + defaultExpanded = false, + className, + }, + ref + ) => { + const [isExpanded, setIsExpanded] = useState( + defaultExpanded || images.length > 0 + ); + const [isDragging, setIsDragging] = useState(false); + const [uploadingFiles, setUploadingFiles] = useState>( + new Set() + ); + const [errorMessage, setErrorMessage] = useState(null); + const fileInputRef = useRef(null); + const latestImagesRef = useRef(images); - const MAX_SIZE = 20 * 1024 * 1024; // 20MB - const VALID_TYPES = [ - 'image/png', - 'image/jpeg', - 'image/jpg', - 'image/gif', - 'image/webp', - 'image/bmp', - 'image/svg+xml', - ]; + useEffect(() => { + latestImagesRef.current = images; + }, [images]); - const invalidFiles: string[] = []; - const oversizedFiles: string[] = []; - const validFiles: File[] = []; - - Array.from(files).forEach((file) => { - if (!VALID_TYPES.includes(file.type.toLowerCase())) { - invalidFiles.push(file.name); - return; - } - - if (file.size > MAX_SIZE) { - oversizedFiles.push( - `${file.name} (${(file.size / 1048576).toFixed(1)} MB)` - ); - return; - } - - validFiles.push(file); - }); - - if (invalidFiles.length > 0 || oversizedFiles.length > 0) { - const errors: string[] = []; - if (invalidFiles.length > 0) { - errors.push(`Unsupported file type: ${invalidFiles.join(', ')}`); - } - if (oversizedFiles.length > 0) { - errors.push( - `Files too large (max 20 MB): ${oversizedFiles.join(', ')}` - ); - } - setErrorMessage(errors.join('. ')); + useEffect(() => { + if (collapsible && images.length > 0 && !isExpanded) { + setIsExpanded(true); } + }, [collapsible, images.length]); - for (const file of validFiles) { - const tempId = `uploading-${Date.now()}-${file.name}`; - setUploadingFiles((prev) => new Set(prev).add(tempId)); + const handleFiles = useCallback( + async (filesInput: FileList | File[] | null) => { + if (!filesInput || disabled || readOnly) return; - try { - const uploadedImage = await onUpload(file); + const files = Array.isArray(filesInput) + ? filesInput + : Array.from(filesInput); - // Call custom upload callback if provided, otherwise use default behavior - if (onImageUploaded) { - onImageUploaded(uploadedImage); - } else { - onImagesChange([...images, uploadedImage]); + setErrorMessage(null); + + const invalidFiles: string[] = []; + const oversizedFiles: string[] = []; + const validFiles: File[] = []; + + files.forEach((file) => { + if (!VALID_TYPES.includes(file.type.toLowerCase())) { + invalidFiles.push(file.name); + return; } - setErrorMessage(null); - } catch (error: any) { - console.error('Failed to upload image:', error); - const message = - error.message || 'Failed to upload image. Please try again.'; - setErrorMessage(message); - } finally { - setUploadingFiles((prev) => { - const next = new Set(prev); - next.delete(tempId); - return next; - }); - } - } - }, - [images, onImagesChange, onImageUploaded, onUpload, disabled] - ); + if (file.size > MAX_SIZE_BYTES) { + oversizedFiles.push( + `${file.name} (${(file.size / 1048576).toFixed(1)} MB)` + ); + return; + } - const handleDrop = useCallback( - (e: React.DragEvent) => { + validFiles.push(file); + }); + + if (invalidFiles.length > 0 || oversizedFiles.length > 0) { + const errors: string[] = []; + if (invalidFiles.length > 0) { + errors.push(`Unsupported file type: ${invalidFiles.join(', ')}`); + } + if (oversizedFiles.length > 0) { + errors.push( + `Files too large (max 20 MB): ${oversizedFiles.join(', ')}` + ); + } + setErrorMessage(errors.join('. ')); + } + + for (const file of validFiles) { + const tempId = `uploading-${Date.now()}-${file.name}`; + setUploadingFiles((prev) => new Set(prev).add(tempId)); + + try { + const uploadedImage = await onUpload(file); + + // Call custom upload callback if provided, otherwise use default behavior + if (onImageUploaded) { + onImageUploaded(uploadedImage); + } else { + const nextImages = [...latestImagesRef.current, uploadedImage]; + latestImagesRef.current = nextImages; + onImagesChange(nextImages); + } + + setErrorMessage(null); + } catch (error: any) { + console.error('Failed to upload image:', error); + const message = + error.message || 'Failed to upload image. Please try again.'; + setErrorMessage(message); + } finally { + setUploadingFiles((prev) => { + const next = new Set(prev); + next.delete(tempId); + return next; + }); + } + } + }, + [disabled, readOnly, onUpload, onImageUploaded, onImagesChange] + ); + + useImperativeHandle( + ref, + () => ({ + addFiles: async (files: FileList | File[] | null) => { + await handleFiles(files); + }, + }), + [handleFiles] + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + void handleFiles(e.dataTransfer.files); + }, + [handleFiles] + ); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); - handleFileSelect(e.dataTransfer.files); - }, - [handleFileSelect] - ); + }, []); - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(true); - }, []); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - }, []); - - const handleRemoveImage = useCallback( - async (imageId: string) => { - if (onDelete) { - try { - await onDelete(imageId); - } catch (error) { - console.error('Failed to delete image:', error); + const handleRemoveImage = useCallback( + async (imageId: string) => { + if (onDelete) { + try { + await onDelete(imageId); + } catch (error) { + console.error('Failed to delete image:', error); + } } + onImagesChange(images.filter((img) => img.id !== imageId)); + }, + [images, onImagesChange, onDelete] + ); + + const formatFileSize = (bytes: bigint) => { + const kb = Number(bytes) / 1024; + if (kb < 1024) { + return `${kb.toFixed(1)} KB`; } - onImagesChange(images.filter((img) => img.id !== imageId)); - }, - [images, onImagesChange, onDelete] - ); + return `${(kb / 1024).toFixed(1)} MB`; + }; - const formatFileSize = (bytes: bigint) => { - const kb = Number(bytes) / 1024; - if (kb < 1024) { - return `${kb.toFixed(1)} KB`; - } - return `${(kb / 1024).toFixed(1)} MB`; - }; + const content = ( +
+ {/* Error message */} + {errorMessage && ( + + + {errorMessage} + + )} - const content = ( -
- {/* Error message */} - {errorMessage && ( - - - {errorMessage} - - )} + {/* Read-only message */} + {readOnly && images.length === 0 && ( +

No images attached

+ )} - {/* Read-only message */} - {readOnly && images.length === 0 && ( -

No images attached

- )} - - {/* Drop zone - only show when not read-only */} - {!readOnly && ( -
- -

- Drag and drop images here, or click to select -

- - handleFileSelect(e.target.files)} - disabled={disabled} - /> -
- )} - - {/* Image previews */} - {images.length > 0 && ( -
- {images.map((image) => ( -
+

+ Drag and drop images here, or click to select +

+ + { + void handleFiles(e.target.files); + }} + disabled={disabled} + /> +
+ )} + + {/* Image previews */} + {images.length > 0 && ( +
+ {images.map((image) => ( +
+
+ {image.original_name} +
+

+ {image.original_name} +

+

+ {formatFileSize(image.size_bytes)} +

+
+ {!disabled && !readOnly && ( + + )}
- {!disabled && !readOnly && ( - - )} -
- ))} -
- )} + ))} +
+ )} - {/* Uploading indicators */} - {uploadingFiles.size > 0 && ( -
- {Array.from(uploadingFiles).map((tempId) => ( -
-
- Uploading... -
- ))} -
- )} -
- ); + {/* Uploading indicators */} + {uploadingFiles.size > 0 && ( +
+ {Array.from(uploadingFiles).map((tempId) => ( +
+
+ Uploading... +
+ ))} +
+ )} +
+ ); - if (!collapsible) { - return content; + if (!collapsible) { + return content; + } + + return ( +
+ + {isExpanded && content} +
+ ); } +); - return ( -
- - {isExpanded && content} -
- ); -} +ImageUploadSection.displayName = 'ImageUploadSection'; diff --git a/frontend/src/components/ui/file-search-textarea.tsx b/frontend/src/components/ui/file-search-textarea.tsx index 7b92871e..23fa2e00 100644 --- a/frontend/src/components/ui/file-search-textarea.tsx +++ b/frontend/src/components/ui/file-search-textarea.tsx @@ -21,6 +21,7 @@ interface FileSearchTextareaProps { maxRows?: number; onCommandEnter?: (e: React.KeyboardEvent) => void; onCommandShiftEnter?: (e: React.KeyboardEvent) => void; + onPasteFiles?: (files: File[]) => void; } export function FileSearchTextarea({ @@ -34,6 +35,7 @@ export function FileSearchTextarea({ onCommandEnter, onCommandShiftEnter, maxRows = 10, + onPasteFiles, }: FileSearchTextareaProps) { const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); @@ -78,6 +80,34 @@ export function FileSearchTextarea({ return () => clearTimeout(debounceTimer); }, [searchQuery, projectId]); + const handlePaste = (e: React.ClipboardEvent) => { + if (!onPasteFiles) return; + + const clipboardData = e.clipboardData; + if (!clipboardData) return; + + const files: File[] = []; + + if (clipboardData.files && clipboardData.files.length > 0) { + files.push(...Array.from(clipboardData.files)); + } else if (clipboardData.items && clipboardData.items.length > 0) { + Array.from(clipboardData.items).forEach((item) => { + if (item.kind !== 'file') return; + const file = item.getAsFile(); + if (file) files.push(file); + }); + } + + const imageFiles = files.filter((file) => + file.type.toLowerCase().startsWith('image/') + ); + + if (imageFiles.length > 0) { + e.preventDefault(); + onPasteFiles(imageFiles); + } + }; + // Handle text changes and detect @ symbol const handleChange = (e: React.ChangeEvent) => { const newValue = e.target.value; @@ -258,6 +288,7 @@ export function FileSearchTextarea({ onKeyDown={handleKeyDown} onCommandEnter={onCommandEnter} onCommandShiftEnter={onCommandShiftEnter} + onPaste={handlePaste} /> {showDropdown &&