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 <gabriel@bloop.ai>

* fmt

---------

Co-authored-by: Greg Burch <gburch@styleseat.com>
Co-authored-by: Gabriel Gordon-Hall <gabriel@bloop.ai>
Co-authored-by: Gabriel Gordon-Hall <ggordonhall@gmail.com>
This commit is contained in:
Greg Burch
2025-10-01 12:17:51 -04:00
committed by GitHub
parent 2781e3651b
commit 0e9d10732a
5 changed files with 385 additions and 273 deletions

View File

@@ -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<TaskFormDialogProps>(
useState<ExecutorProfileId | null>(null);
const [quickstartExpanded, setQuickstartExpanded] =
useState<boolean>(false);
const imageUploadRef = useRef<ImageUploadSectionHandle>(null);
const isEditMode = Boolean(task);
@@ -290,6 +294,11 @@ export const TaskFormDialog = NiceModal.create<TaskFormDialogProps>(
);
}, []);
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<TaskFormDialogProps>(
isEditMode ? handleSubmit : handleCreateAndStart
}
onCommandShiftEnter={handleSubmit}
onPasteFiles={handlePasteImages}
/>
</div>
<ImageUploadSection
ref={imageUploadRef}
images={images}
onImagesChange={handleImagesChange}
onUpload={imagesApi.upload}

View File

@@ -6,10 +6,13 @@ import {
AlertCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ImageUploadSection } from '@/components/ui/ImageUploadSection';
import {
ImageUploadSection,
type ImageUploadSectionHandle,
} from '@/components/ui/ImageUploadSection';
import { Alert, AlertDescription } from '@/components/ui/alert';
//
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { imagesApi } from '@/lib/api.ts';
import type { TaskWithAttemptStatus } from 'shared/types';
import { useBranchStatus } from '@/hooks';
@@ -106,6 +109,13 @@ export function TaskFollowUpSection({
// Presentation-only: show/hide image upload panel
const [showImageUpload, setShowImageUpload] = useState(false);
const imageUploadRef = useRef<ImageUploadSectionHandle>(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({
</Alert>
)}
<div className="space-y-2">
{showImageUpload && (
<div className="mb-2">
<ImageUploadSection
images={images}
onImagesChange={setImages}
onUpload={(file) => imagesApi.uploadForTask(task.id, file)}
onDelete={imagesApi.delete}
onImageUploaded={(image) => {
handleImageUploaded(image);
setFollowUpMessage((prev) =>
appendImageMarkdown(prev, image)
);
}}
disabled={!isEditable}
collapsible={false}
defaultExpanded={true}
/>
</div>
)}
<div
className={cn(
'mb-2',
!showImageUpload && images.length === 0 && 'hidden'
)}
>
<ImageUploadSection
ref={imageUploadRef}
images={images}
onImagesChange={setImages}
onUpload={(file) => imagesApi.uploadForTask(task.id, file)}
onDelete={imagesApi.delete}
onImageUploaded={(image) => {
handleImageUploaded(image);
setFollowUpMessage((prev) =>
appendImageMarkdown(prev, image)
);
}}
disabled={!isEditable}
collapsible={false}
defaultExpanded={true}
/>
</div>
{/* Review comments preview */}
{reviewMarkdown && (
@@ -352,6 +366,7 @@ export function TaskFollowUpSection({
showLoadingOverlay={isUnqueuing || !isDraftLoaded}
onCommandEnter={onSendFollowUp}
onCommandShiftEnter={onSendFollowUp}
onPasteFiles={handlePasteImages}
/>
<FollowUpStatusRow
status={{

View File

@@ -13,6 +13,7 @@ type Props = {
showLoadingOverlay: boolean;
onCommandEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onCommandShiftEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => 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 && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-background/60">

View File

@@ -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<Set<string>>(new Set());
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
export interface ImageUploadSectionHandle {
addFiles: (files: FileList | File[] | null) => Promise<void>;
}
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<Set<string>>(
new Set()
);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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 = (
<div className={cn('space-y-3', className)}>
{/* Error message */}
{errorMessage && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
const content = (
<div className={cn('space-y-3', className)}>
{/* Error message */}
{errorMessage && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
{/* Read-only message */}
{readOnly && images.length === 0 && (
<p className="text-sm text-muted-foreground">No images attached</p>
)}
{/* Read-only message */}
{readOnly && images.length === 0 && (
<p className="text-sm text-muted-foreground">No images attached</p>
)}
{/* Drop zone - only show when not read-only */}
{!readOnly && (
<div
className={cn(
'border-2 border-dashed rounded-lg p-6 text-center transition-colors',
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-muted-foreground/50',
disabled && 'opacity-50 cursor-not-allowed'
)}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
<Upload className="h-8 w-8 mx-auto mb-3 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-1">
Drag and drop images here, or click to select
</p>
<Button
variant="secondary"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || isUploading}
{/* Drop zone - only show when not read-only */}
{!readOnly && (
<div
className={cn(
'border-2 border-dashed rounded-lg p-6 text-center transition-colors',
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-muted-foreground/50',
disabled && 'opacity-50 cursor-not-allowed'
)}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
Select Images
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => handleFileSelect(e.target.files)}
disabled={disabled}
/>
</div>
)}
{/* Image previews */}
{images.length > 0 && (
<div className="grid grid-cols-2 gap-2">
{images.map((image) => (
<div
key={image.id}
className="relative group border rounded-lg p-2 bg-background"
<Upload className="h-8 w-8 mx-auto mb-3 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-1">
Drag and drop images here, or click to select
</p>
<Button
variant="secondary"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || isUploading}
>
<div className="flex items-center gap-2">
<img
src={imagesApi.getImageUrl(image.id)}
alt={image.original_name}
className="h-16 w-16 object-cover rounded"
/>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">
{image.original_name}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(image.size_bytes)}
</p>
Select Images
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
void handleFiles(e.target.files);
}}
disabled={disabled}
/>
</div>
)}
{/* Image previews */}
{images.length > 0 && (
<div className="grid grid-cols-2 gap-2">
{images.map((image) => (
<div
key={image.id}
className="relative group border rounded-lg p-2 bg-background"
>
<div className="flex items-center gap-2">
<img
src={imagesApi.getImageUrl(image.id)}
alt={image.original_name}
className="h-16 w-16 object-cover rounded"
/>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">
{image.original_name}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(image.size_bytes)}
</p>
</div>
</div>
{!disabled && !readOnly && (
<Button
variant="ghost"
size="icon"
className="absolute top-1 right-1 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => handleRemoveImage(image.id)}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
{!disabled && !readOnly && (
<Button
variant="ghost"
size="icon"
className="absolute top-1 right-1 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => handleRemoveImage(image.id)}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
))}
</div>
)}
))}
</div>
)}
{/* Uploading indicators */}
{uploadingFiles.size > 0 && (
<div className="space-y-1">
{Array.from(uploadingFiles).map((tempId) => (
<div
key={tempId}
className="flex items-center gap-2 text-xs text-muted-foreground"
>
<div className="h-3 w-3 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span>Uploading...</span>
</div>
))}
</div>
)}
</div>
);
{/* Uploading indicators */}
{uploadingFiles.size > 0 && (
<div className="space-y-1">
{Array.from(uploadingFiles).map((tempId) => (
<div
key={tempId}
className="flex items-center gap-2 text-xs text-muted-foreground"
>
<div className="h-3 w-3 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span>Uploading...</span>
</div>
))}
</div>
)}
</div>
);
if (!collapsible) {
return content;
if (!collapsible) {
return content;
}
return (
<div className="space-y-2">
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight
className={cn(
'h-3 w-3 transition-transform',
isExpanded && 'rotate-90'
)}
/>
<ImageIcon className="h-4 w-4" />
<span>Images {images.length > 0 && `(${images.length})`}</span>
</button>
{isExpanded && content}
</div>
);
}
);
return (
<div className="space-y-2">
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight
className={cn(
'h-3 w-3 transition-transform',
isExpanded && 'rotate-90'
)}
/>
<ImageIcon className="h-4 w-4" />
<span>Images {images.length > 0 && `(${images.length})`}</span>
</button>
{isExpanded && content}
</div>
);
}
ImageUploadSection.displayName = 'ImageUploadSection';

View File

@@ -21,6 +21,7 @@ interface FileSearchTextareaProps {
maxRows?: number;
onCommandEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onCommandShiftEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => 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<FileSearchResult[]>([]);
@@ -78,6 +80,34 @@ export function FileSearchTextarea({
return () => clearTimeout(debounceTimer);
}, [searchQuery, projectId]);
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
const newValue = e.target.value;
@@ -258,6 +288,7 @@ export function FileSearchTextarea({
onKeyDown={handleKeyDown}
onCommandEnter={onCommandEnter}
onCommandShiftEnter={onCommandShiftEnter}
onPaste={handlePaste}
/>
{showDropdown &&