Add file/screenshot upload to QA feedback interface (#1018)
* auto-claude: subtask-1-1 - Add feedbackImages state and handlers to useTaskDetail - Add feedbackImages state as ImageAttachment[] for storing feedback images - Add setFeedbackImages setter for direct state updates - Add addFeedbackImage handler for adding a single image - Add addFeedbackImages handler for adding multiple images at once - Add removeFeedbackImage handler for removing an image by ID - Add clearFeedbackImages handler for clearing all images - Import ImageAttachment type from shared/types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-1-2 - Update IPC interface to support images in submitReview - Add ImageAttachment import from ./task types - Update submitReview signature to include optional images parameter 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-1-3 - Update submitReview function in task-store to accept and pass images * auto-claude: subtask-2-1 - Add paste/drop handlers and image thumbnail displa - Add paste event handler for screenshot/image clipboard support - Add drag-over and drag-leave handlers for visual feedback during drag - Add drop handler for image file drops - Add image thumbnail display (64x64) with remove button on hover - Import image utilities from ImageUpload.tsx (generateImageId, blobToBase64, etc.) - Add i18n support for all new UI text - Make new props optional for backward compatibility during incremental rollout - Allow submission with either text feedback or images (not both required) - Add visual drag feedback with border/background color change * auto-claude: subtask-2-2 - Update TaskReview to pass image props to QAFeedbackSection * auto-claude: subtask-2-3 - Update TaskDetailModal to manage image state and pass to TaskReview - Pass feedbackImages and setFeedbackImages from useTaskDetail hook to TaskReview - Update handleReject to include images in submitReview call - Allow submission with images only (no text required) - Clear images after successful submission * auto-claude: subtask-3-1 - Add English translations for feedback image UI * auto-claude: subtask-3-2 - Add French translations for feedback image UI * fix(security): sanitize image filename to prevent path traversal - Use path.basename() to strip directory components from filenames - Validate sanitized filename is not empty, '.', or '..' - Add defense-in-depth check verifying resolved path stays within target directory - Fix base64 data URL regex to handle complex MIME types (e.g., svg+xml) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: add MIME type validation and fix SVG file extension - Add server-side MIME type validation for image uploads (defense in depth) - Fix SVG file extension: map 'image/svg+xml' to '.svg' instead of '.svg+xml' - Add MIME-to-extension mapping for all allowed image types Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: require mimeType and apply SVG extension fix to drop handler - Change MIME validation to reject missing mimeType (prevents bypass) - Add 'image/jpg' to server-side allowlist for consistency - Apply mimeToExtension mapping to drop handler (was only in paste handler) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Test User <test@example.com>
This commit is contained in:
@@ -148,7 +148,8 @@ describe('IPC Bridge Integration', () => {
|
||||
const submitReview = electronAPI['submitReview'] as (
|
||||
id: string,
|
||||
approved: boolean,
|
||||
feedback?: string
|
||||
feedback?: string,
|
||||
images?: unknown[]
|
||||
) => Promise<unknown>;
|
||||
await submitReview('task-id', false, 'Needs more work');
|
||||
|
||||
@@ -156,7 +157,8 @@ describe('IPC Bridge Integration', () => {
|
||||
'task:review',
|
||||
'task-id',
|
||||
false,
|
||||
'Needs more work'
|
||||
'Needs more work',
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ipcMain, BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/constants';
|
||||
import type { IPCResult, TaskStartOptions, TaskStatus } from '../../../shared/types';
|
||||
import type { IPCResult, TaskStartOptions, TaskStatus, ImageAttachment } from '../../../shared/types';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, mkdirSync } from 'fs';
|
||||
import { spawnSync, execFileSync } from 'child_process';
|
||||
import { getToolPath } from '../../cli-tool-manager';
|
||||
import { AgentManager } from '../../agent';
|
||||
@@ -318,7 +318,8 @@ export function registerTaskExecutionHandlers(
|
||||
_,
|
||||
taskId: string,
|
||||
approved: boolean,
|
||||
feedback?: string
|
||||
feedback?: string,
|
||||
images?: ImageAttachment[]
|
||||
): Promise<IPCResult> => {
|
||||
// Find task and project
|
||||
const { task, project } = findTaskAndProject(taskId);
|
||||
@@ -407,10 +408,65 @@ export function registerTaskExecutionHandlers(
|
||||
console.warn('[TASK_REVIEW] Writing QA fix request to:', fixRequestPath);
|
||||
console.warn('[TASK_REVIEW] hasWorktree:', hasWorktree, 'worktreePath:', worktreePath);
|
||||
|
||||
// Process images if provided
|
||||
let imageReferences = '';
|
||||
if (images && images.length > 0) {
|
||||
const imagesDir = path.join(targetSpecDir, 'feedback_images');
|
||||
try {
|
||||
if (!existsSync(imagesDir)) {
|
||||
mkdirSync(imagesDir, { recursive: true });
|
||||
}
|
||||
const savedImages: string[] = [];
|
||||
for (const image of images) {
|
||||
try {
|
||||
if (!image.data) {
|
||||
console.warn('[TASK_REVIEW] Skipping image with no data:', image.filename);
|
||||
continue;
|
||||
}
|
||||
// Server-side MIME type validation (defense in depth - frontend also validates)
|
||||
// Reject missing mimeType to prevent bypass attacks
|
||||
const ALLOWED_MIME_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp', 'image/svg+xml'];
|
||||
if (!image.mimeType || !ALLOWED_MIME_TYPES.includes(image.mimeType)) {
|
||||
console.warn('[TASK_REVIEW] Skipping image with missing or disallowed MIME type:', image.mimeType);
|
||||
continue;
|
||||
}
|
||||
// Sanitize filename to prevent path traversal attacks
|
||||
const sanitizedFilename = path.basename(image.filename);
|
||||
if (!sanitizedFilename || sanitizedFilename === '.' || sanitizedFilename === '..') {
|
||||
console.warn('[TASK_REVIEW] Skipping image with invalid filename:', image.filename);
|
||||
continue;
|
||||
}
|
||||
// Remove data URL prefix if present (e.g., "data:image/png;base64," or "data:image/svg+xml;base64,")
|
||||
const base64Data = image.data.replace(/^data:image\/[^;]+;base64,/, '');
|
||||
const imageBuffer = Buffer.from(base64Data, 'base64');
|
||||
const imagePath = path.join(imagesDir, sanitizedFilename);
|
||||
// Verify the resolved path is within the images directory (defense in depth)
|
||||
const resolvedPath = path.resolve(imagePath);
|
||||
const resolvedImagesDir = path.resolve(imagesDir);
|
||||
if (!resolvedPath.startsWith(resolvedImagesDir + path.sep)) {
|
||||
console.warn('[TASK_REVIEW] Skipping image with path outside target directory:', image.filename);
|
||||
continue;
|
||||
}
|
||||
writeFileSync(imagePath, imageBuffer);
|
||||
savedImages.push(`feedback_images/${sanitizedFilename}`);
|
||||
console.log('[TASK_REVIEW] Saved image:', sanitizedFilename);
|
||||
} catch (imgError) {
|
||||
console.error('[TASK_REVIEW] Failed to save image:', image.filename, imgError);
|
||||
}
|
||||
}
|
||||
if (savedImages.length > 0) {
|
||||
imageReferences = '\n\n## Reference Images\n\n' +
|
||||
savedImages.map(imgPath => ``).join('\n\n');
|
||||
}
|
||||
} catch (dirError) {
|
||||
console.error('[TASK_REVIEW] Failed to create images directory:', dirError);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(
|
||||
fixRequestPath,
|
||||
`# QA Fix Request\n\nStatus: REJECTED\n\n## Feedback\n\n${feedback || 'No feedback provided'}\n\nCreated at: ${new Date().toISOString()}\n`
|
||||
`# QA Fix Request\n\nStatus: REJECTED\n\n## Feedback\n\n${feedback || 'No feedback provided'}${imageReferences}\n\nCreated at: ${new Date().toISOString()}\n`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[TASK_REVIEW] Failed to write QA fix request:', error);
|
||||
|
||||
@@ -13,7 +13,8 @@ import type {
|
||||
SupportedIDE,
|
||||
SupportedTerminal,
|
||||
WorktreeCreatePROptions,
|
||||
WorktreeCreatePRResult
|
||||
WorktreeCreatePRResult,
|
||||
ImageAttachment
|
||||
} from '../../shared/types';
|
||||
|
||||
export interface TaskAPI {
|
||||
@@ -35,7 +36,8 @@ export interface TaskAPI {
|
||||
submitReview: (
|
||||
taskId: string,
|
||||
approved: boolean,
|
||||
feedback?: string
|
||||
feedback?: string,
|
||||
images?: ImageAttachment[]
|
||||
) => Promise<IPCResult>;
|
||||
updateTaskStatus: (
|
||||
taskId: string,
|
||||
@@ -112,9 +114,10 @@ export const createTaskAPI = (): TaskAPI => ({
|
||||
submitReview: (
|
||||
taskId: string,
|
||||
approved: boolean,
|
||||
feedback?: string
|
||||
feedback?: string,
|
||||
images?: ImageAttachment[]
|
||||
): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_REVIEW, taskId, approved, feedback),
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_REVIEW, taskId, approved, feedback, images),
|
||||
|
||||
updateTaskStatus: (
|
||||
taskId: string,
|
||||
|
||||
@@ -118,13 +118,15 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!state.feedback.trim()) {
|
||||
// Allow submission if there's text feedback OR images attached
|
||||
if (!state.feedback.trim() && state.feedbackImages.length === 0) {
|
||||
return;
|
||||
}
|
||||
state.setIsSubmitting(true);
|
||||
await submitReview(task.id, false, state.feedback);
|
||||
await submitReview(task.id, false, state.feedback, state.feedbackImages);
|
||||
state.setIsSubmitting(false);
|
||||
state.setFeedback('');
|
||||
state.setFeedbackImages([]);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
@@ -516,6 +518,8 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
|
||||
showConflictDialog={state.showConflictDialog}
|
||||
onFeedbackChange={state.setFeedback}
|
||||
onReject={handleReject}
|
||||
images={state.feedbackImages}
|
||||
onImagesChange={state.setFeedbackImages}
|
||||
onMerge={handleMerge}
|
||||
onDiscard={handleDiscard}
|
||||
onShowDiscardDialog={state.setShowDiscardDialog}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Task, WorktreeStatus, WorktreeDiff, MergeConflict, MergeStats, GitConflictInfo, WorktreeCreatePRResult } from '../../../shared/types';
|
||||
import type { Task, WorktreeStatus, WorktreeDiff, MergeConflict, MergeStats, GitConflictInfo, ImageAttachment, WorktreeCreatePRResult } from '../../../shared/types';
|
||||
import {
|
||||
StagedSuccessMessage,
|
||||
WorkspaceStatus,
|
||||
@@ -33,6 +33,10 @@ interface TaskReviewProps {
|
||||
showConflictDialog: boolean;
|
||||
onFeedbackChange: (value: string) => void;
|
||||
onReject: () => void;
|
||||
/** Image attachments for visual feedback */
|
||||
images?: ImageAttachment[];
|
||||
/** Callback when images change */
|
||||
onImagesChange?: (images: ImageAttachment[]) => void;
|
||||
onMerge: () => void;
|
||||
onDiscard: () => void;
|
||||
onShowDiscardDialog: (show: boolean) => void;
|
||||
@@ -81,6 +85,8 @@ export function TaskReview({
|
||||
showConflictDialog,
|
||||
onFeedbackChange,
|
||||
onReject,
|
||||
images,
|
||||
onImagesChange,
|
||||
onMerge,
|
||||
onDiscard,
|
||||
onShowDiscardDialog,
|
||||
@@ -157,6 +163,8 @@ export function TaskReview({
|
||||
isSubmitting={isSubmitting}
|
||||
onFeedbackChange={onFeedbackChange}
|
||||
onReject={onReject}
|
||||
images={images}
|
||||
onImagesChange={onImagesChange}
|
||||
/>
|
||||
|
||||
{/* Discard Confirmation Dialog */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import { checkTaskRunning, isIncompleteHumanReview, getTaskProgress, useTaskStore, loadTasks } from '../../../stores/task-store';
|
||||
import type { Task, TaskLogs, TaskLogPhase, WorktreeStatus, WorktreeDiff, MergeConflict, MergeStats, GitConflictInfo } from '../../../../shared/types';
|
||||
import type { Task, TaskLogs, TaskLogPhase, WorktreeStatus, WorktreeDiff, MergeConflict, MergeStats, GitConflictInfo, ImageAttachment } from '../../../../shared/types';
|
||||
|
||||
/**
|
||||
* Validates task subtasks structure to prevent infinite loops during resume.
|
||||
@@ -50,6 +50,7 @@ export interface UseTaskDetailOptions {
|
||||
|
||||
export function useTaskDetail({ task }: UseTaskDetailOptions) {
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [feedbackImages, setFeedbackImages] = useState<ImageAttachment[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
|
||||
@@ -161,6 +162,11 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// Reset feedback images when task changes to prevent image leakage between tasks
|
||||
useEffect(() => {
|
||||
setFeedbackImages([]);
|
||||
}, [task.id]);
|
||||
|
||||
// Load worktree status when task is in human_review
|
||||
useEffect(() => {
|
||||
if (needsReview) {
|
||||
@@ -255,6 +261,26 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Add a feedback image
|
||||
const addFeedbackImage = useCallback((image: ImageAttachment) => {
|
||||
setFeedbackImages(prev => [...prev, image]);
|
||||
}, []);
|
||||
|
||||
// Add multiple feedback images at once
|
||||
const addFeedbackImages = useCallback((images: ImageAttachment[]) => {
|
||||
setFeedbackImages(prev => [...prev, ...images]);
|
||||
}, []);
|
||||
|
||||
// Remove a feedback image by ID
|
||||
const removeFeedbackImage = useCallback((imageId: string) => {
|
||||
setFeedbackImages(prev => prev.filter(img => img.id !== imageId));
|
||||
}, []);
|
||||
|
||||
// Clear all feedback images
|
||||
const clearFeedbackImages = useCallback(() => {
|
||||
setFeedbackImages([]);
|
||||
}, []);
|
||||
|
||||
// Track if we've already loaded preview for this task to prevent infinite loops
|
||||
const hasLoadedPreviewRef = useRef<string | null>(null);
|
||||
|
||||
@@ -404,6 +430,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
|
||||
return {
|
||||
// State
|
||||
feedback,
|
||||
feedbackImages,
|
||||
isSubmitting,
|
||||
activeTab,
|
||||
isUserScrolledUp,
|
||||
@@ -447,6 +474,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
|
||||
|
||||
// Setters
|
||||
setFeedback,
|
||||
setFeedbackImages,
|
||||
setIsSubmitting,
|
||||
setActiveTab,
|
||||
setIsUserScrolledUp,
|
||||
@@ -482,6 +510,10 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
|
||||
handleLogsScroll,
|
||||
togglePhase,
|
||||
loadMergePreview,
|
||||
addFeedbackImage,
|
||||
addFeedbackImages,
|
||||
removeFeedbackImage,
|
||||
clearFeedbackImages,
|
||||
handleReviewAgain,
|
||||
reloadPlanForIncompleteTask,
|
||||
};
|
||||
|
||||
+331
-9
@@ -1,54 +1,376 @@
|
||||
import { AlertCircle, RotateCcw, Loader2 } from 'lucide-react';
|
||||
import { useCallback, useRef, useState, type ClipboardEvent, type DragEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertCircle, RotateCcw, Loader2, Image as ImageIcon, X } from 'lucide-react';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Textarea } from '../../ui/textarea';
|
||||
import {
|
||||
generateImageId,
|
||||
blobToBase64,
|
||||
createThumbnail,
|
||||
isValidImageMimeType,
|
||||
resolveFilename
|
||||
} from '../../ImageUpload';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { ImageAttachment } from '../../../../shared/types';
|
||||
import {
|
||||
MAX_IMAGES_PER_TASK,
|
||||
ALLOWED_IMAGE_TYPES_DISPLAY
|
||||
} from '../../../../shared/constants';
|
||||
|
||||
interface QAFeedbackSectionProps {
|
||||
feedback: string;
|
||||
isSubmitting: boolean;
|
||||
onFeedbackChange: (value: string) => void;
|
||||
onReject: () => void;
|
||||
/** Image attachments for visual feedback - optional for backward compatibility */
|
||||
images?: ImageAttachment[];
|
||||
/** Callback when images change - optional for backward compatibility */
|
||||
onImagesChange?: (images: ImageAttachment[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the QA feedback section where users can request changes
|
||||
* Supports image paste and drag-drop for visual feedback
|
||||
*/
|
||||
export function QAFeedbackSection({
|
||||
feedback,
|
||||
isSubmitting,
|
||||
onFeedbackChange,
|
||||
onReject
|
||||
onReject,
|
||||
images = [],
|
||||
onImagesChange
|
||||
}: QAFeedbackSectionProps) {
|
||||
const { t } = useTranslation('tasks');
|
||||
|
||||
// Feature is enabled when onImagesChange callback is provided
|
||||
const imageUploadEnabled = !!onImagesChange;
|
||||
|
||||
// Ref for the textarea
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Local state for UI feedback
|
||||
const [isDragOverTextarea, setIsDragOverTextarea] = useState(false);
|
||||
const [pasteSuccess, setPasteSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Handle paste event for screenshot support
|
||||
*/
|
||||
const handlePaste = useCallback(async (e: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
// Skip image handling if feature is not enabled
|
||||
if (!onImagesChange) return;
|
||||
|
||||
const clipboardItems = e.clipboardData?.items;
|
||||
if (!clipboardItems) return;
|
||||
|
||||
// Find image items in clipboard
|
||||
const imageItems: DataTransferItem[] = [];
|
||||
for (let i = 0; i < clipboardItems.length; i++) {
|
||||
const item = clipboardItems[i];
|
||||
if (item.type.startsWith('image/')) {
|
||||
imageItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// If no images, allow normal paste behavior
|
||||
if (imageItems.length === 0) return;
|
||||
|
||||
// Prevent default paste when we have images
|
||||
e.preventDefault();
|
||||
|
||||
// Check if we can add more images
|
||||
const remainingSlots = MAX_IMAGES_PER_TASK - images.length;
|
||||
if (remainingSlots <= 0) {
|
||||
setError(t('feedback.maxImagesError', { count: MAX_IMAGES_PER_TASK }));
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
// Process image items
|
||||
const newImages: ImageAttachment[] = [];
|
||||
const existingFilenames = images.map(img => img.filename);
|
||||
|
||||
for (const item of imageItems.slice(0, remainingSlots)) {
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
|
||||
// Validate image type
|
||||
if (!isValidImageMimeType(file.type)) {
|
||||
setError(t('feedback.invalidTypeError', { types: ALLOWED_IMAGE_TYPES_DISPLAY }));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = await blobToBase64(file);
|
||||
const thumbnail = await createThumbnail(dataUrl);
|
||||
|
||||
// Generate filename for pasted images (screenshot-timestamp.ext)
|
||||
// Map MIME types to proper file extensions (handles svg+xml -> svg, etc.)
|
||||
const mimeToExtension: Record<string, string> = {
|
||||
'image/svg+xml': 'svg',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
};
|
||||
const extension = mimeToExtension[file.type] || file.type.split('/')[1] || 'png';
|
||||
const baseFilename = `screenshot-${Date.now()}.${extension}`;
|
||||
const resolvedFilename = resolveFilename(baseFilename, [
|
||||
...existingFilenames,
|
||||
...newImages.map(img => img.filename)
|
||||
]);
|
||||
|
||||
newImages.push({
|
||||
id: generateImageId(),
|
||||
filename: resolvedFilename,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
data: dataUrl.split(',')[1], // Store base64 without data URL prefix
|
||||
thumbnail
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[QAFeedbackSection] Failed to process pasted image:', error);
|
||||
setError(t('feedback.processingError', 'Failed to process pasted image'));
|
||||
}
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
onImagesChange([...images, ...newImages]);
|
||||
// Show success feedback
|
||||
setPasteSuccess(true);
|
||||
setTimeout(() => setPasteSuccess(false), 2000);
|
||||
}
|
||||
}, [images, onImagesChange, t]);
|
||||
|
||||
/**
|
||||
* Handle drag over textarea for image drops
|
||||
*/
|
||||
const handleTextareaDragOver = useCallback((e: DragEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOverTextarea(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle drag leave from textarea
|
||||
*/
|
||||
const handleTextareaDragLeave = useCallback((e: DragEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOverTextarea(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle drop on textarea for images
|
||||
*/
|
||||
const handleTextareaDrop = useCallback(
|
||||
async (e: DragEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOverTextarea(false);
|
||||
|
||||
// Skip image handling if feature is not enabled
|
||||
if (!onImagesChange) return;
|
||||
if (isSubmitting) return;
|
||||
|
||||
const files = e.dataTransfer?.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
// Filter for image files
|
||||
const imageFiles: File[] = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (file.type.startsWith('image/')) {
|
||||
imageFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (imageFiles.length === 0) return;
|
||||
|
||||
// Check if we can add more images
|
||||
const remainingSlots = MAX_IMAGES_PER_TASK - images.length;
|
||||
if (remainingSlots <= 0) {
|
||||
setError(t('feedback.maxImagesError', { count: MAX_IMAGES_PER_TASK }));
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
// Process image files
|
||||
const newImages: ImageAttachment[] = [];
|
||||
const existingFilenames = images.map(img => img.filename);
|
||||
|
||||
for (const file of imageFiles.slice(0, remainingSlots)) {
|
||||
// Validate image type
|
||||
if (!isValidImageMimeType(file.type)) {
|
||||
setError(t('feedback.invalidTypeError', { types: ALLOWED_IMAGE_TYPES_DISPLAY }));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = await blobToBase64(file);
|
||||
const thumbnail = await createThumbnail(dataUrl);
|
||||
|
||||
// Use original filename or generate one with proper extension
|
||||
// Map MIME types to proper file extensions (handles svg+xml -> svg, etc.)
|
||||
const mimeToExtension: Record<string, string> = {
|
||||
'image/svg+xml': 'svg',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
};
|
||||
const extension = mimeToExtension[file.type] || file.type.split('/')[1] || 'png';
|
||||
const baseFilename = file.name || `dropped-image-${Date.now()}.${extension}`;
|
||||
const resolvedFilename = resolveFilename(baseFilename, [
|
||||
...existingFilenames,
|
||||
...newImages.map(img => img.filename)
|
||||
]);
|
||||
|
||||
newImages.push({
|
||||
id: generateImageId(),
|
||||
filename: resolvedFilename,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
data: dataUrl.split(',')[1], // Store base64 without data URL prefix
|
||||
thumbnail
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[QAFeedbackSection] Failed to process dropped image:', error);
|
||||
setError(t('feedback.processingError', 'Failed to process dropped image'));
|
||||
}
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
onImagesChange([...images, ...newImages]);
|
||||
// Show success feedback
|
||||
setPasteSuccess(true);
|
||||
setTimeout(() => setPasteSuccess(false), 2000);
|
||||
}
|
||||
},
|
||||
[images, isSubmitting, onImagesChange, t]
|
||||
);
|
||||
|
||||
/**
|
||||
* Remove an image from the attachments
|
||||
*/
|
||||
const handleRemoveImage = useCallback((imageId: string) => {
|
||||
if (!onImagesChange) return;
|
||||
onImagesChange(images.filter(img => img.id !== imageId));
|
||||
setError(null);
|
||||
}, [images, onImagesChange]);
|
||||
|
||||
// Allow submission with either text feedback or images
|
||||
const canSubmit = feedback.trim() || images.length > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-warning/30 bg-warning/10 p-4">
|
||||
<h3 className="font-medium text-sm text-foreground mb-2 flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-warning" />
|
||||
Request Changes
|
||||
{t('feedback.requestChanges', 'Request Changes')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Found issues? Describe what needs to be fixed and the AI will continue working on it.
|
||||
{t('feedback.description', 'Found issues? Describe what needs to be fixed and the AI will continue working on it.')}
|
||||
</p>
|
||||
|
||||
{/* Textarea with paste/drop support */}
|
||||
<Textarea
|
||||
placeholder="Describe the issues or changes needed..."
|
||||
ref={textareaRef}
|
||||
placeholder={t('feedback.placeholder', 'Describe the issues or changes needed...')}
|
||||
value={feedback}
|
||||
onChange={(e) => onFeedbackChange(e.target.value)}
|
||||
className="mb-3"
|
||||
onPaste={handlePaste}
|
||||
onDragOver={handleTextareaDragOver}
|
||||
onDragLeave={handleTextareaDragLeave}
|
||||
onDrop={handleTextareaDrop}
|
||||
className={cn(
|
||||
"mb-2",
|
||||
// Visual feedback when dragging over textarea
|
||||
isDragOverTextarea && !isSubmitting && "border-primary bg-primary/5 ring-2 ring-primary/20"
|
||||
)}
|
||||
rows={3}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
|
||||
{/* Drag/paste hint - only show when feature is enabled */}
|
||||
{imageUploadEnabled && (
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{t('feedback.dragDropHint', 'Drag & drop images or paste screenshots')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Paste Success Indicator */}
|
||||
{pasteSuccess && (
|
||||
<div className="flex items-center gap-2 text-sm text-success mb-2 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
{t('feedback.imageAdded', 'Image added successfully!')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 border border-destructive/30 p-2 text-sm text-destructive mb-2">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Thumbnails - displayed inline below textarea */}
|
||||
{images.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative group rounded-md border border-border overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
|
||||
style={{ width: '64px', height: '64px' }}
|
||||
title={image.filename}
|
||||
>
|
||||
{image.thumbnail ? (
|
||||
<img
|
||||
src={image.thumbnail}
|
||||
alt={image.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-muted">
|
||||
<ImageIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{/* Remove button */}
|
||||
{!isSubmitting && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-0.5 right-0.5 h-4 w-4 flex items-center justify-center rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveImage(image.id);
|
||||
}}
|
||||
aria-label={t('feedback.removeImage', 'Remove image')}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="warning"
|
||||
onClick={onReject}
|
||||
disabled={isSubmitting || !feedback.trim()}
|
||||
disabled={isSubmitting || !canSubmit}
|
||||
className="w-full"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Submitting...
|
||||
{t('feedback.submitting', 'Submitting...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Request Changes
|
||||
{t('feedback.requestChanges', 'Request Changes')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Task, TaskStatus, SubtaskStatus, ImplementationPlan, Subtask, TaskMetadata, ExecutionProgress, ExecutionPhase, ReviewReason, TaskDraft } from '../../shared/types';
|
||||
import type { Task, TaskStatus, SubtaskStatus, ImplementationPlan, Subtask, TaskMetadata, ExecutionProgress, ExecutionPhase, ReviewReason, TaskDraft, ImageAttachment } from '../../shared/types';
|
||||
import { debugLog } from '../../shared/utils/debug-logger';
|
||||
import { isTerminalPhase } from '../../shared/constants/phase-protocol';
|
||||
|
||||
@@ -493,12 +493,13 @@ export function stopTask(taskId: string): void {
|
||||
export async function submitReview(
|
||||
taskId: string,
|
||||
approved: boolean,
|
||||
feedback?: string
|
||||
feedback?: string,
|
||||
images?: ImageAttachment[]
|
||||
): Promise<boolean> {
|
||||
const store = useTaskStore.getState();
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.submitReview(taskId, approved, feedback);
|
||||
const result = await window.electronAPI.submitReview(taskId, approved, feedback, images);
|
||||
if (result.success) {
|
||||
store.updateTaskStatus(taskId, approved ? 'done' : 'in_progress');
|
||||
return true;
|
||||
|
||||
@@ -162,6 +162,14 @@
|
||||
"createFailed": "Failed to create task. Please try again."
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"dragDropHint": "Drag & drop images or paste screenshots",
|
||||
"imageAdded": "Image added successfully",
|
||||
"maxImagesError": "Maximum of {{count}} images allowed",
|
||||
"invalidTypeError": "Invalid image type. Allowed: {{types}}",
|
||||
"removeImage": "Remove image",
|
||||
"processingError": "Failed to process image"
|
||||
},
|
||||
"review": {
|
||||
"mergeTooltip": "Merges changes from the task's worktree branch back to your base branch. AI will resolve any conflicts. You can then choose whether to keep or remove the worktree."
|
||||
},
|
||||
|
||||
@@ -162,6 +162,14 @@
|
||||
"createFailed": "Échec de la création de la tâche. Veuillez réessayer."
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"dragDropHint": "Glissez-déposez des images ou collez des captures d'écran",
|
||||
"imageAdded": "Image ajoutée avec succès",
|
||||
"maxImagesError": "Maximum de {{count}} images autorisées",
|
||||
"invalidTypeError": "Type d'image invalide. Autorisés : {{types}}",
|
||||
"removeImage": "Supprimer l'image",
|
||||
"processingError": "Échec du traitement de l'image"
|
||||
},
|
||||
"review": {
|
||||
"mergeTooltip": "Fusionne les changements de la branche worktree de la tâche vers votre branche de base. L'IA résoudra les conflits éventuels. Vous pourrez ensuite choisir de conserver ou de supprimer le worktree."
|
||||
},
|
||||
|
||||
@@ -42,7 +42,8 @@ import type {
|
||||
TaskRecoveryOptions,
|
||||
TaskMetadata,
|
||||
TaskLogs,
|
||||
TaskLogStreamChunk
|
||||
TaskLogStreamChunk,
|
||||
ImageAttachment
|
||||
} from './task';
|
||||
import type {
|
||||
TerminalCreateOptions,
|
||||
@@ -158,7 +159,7 @@ export interface ElectronAPI {
|
||||
updateTask: (taskId: string, updates: { title?: string; description?: string }) => Promise<IPCResult<Task>>;
|
||||
startTask: (taskId: string, options?: TaskStartOptions) => void;
|
||||
stopTask: (taskId: string) => void;
|
||||
submitReview: (taskId: string, approved: boolean, feedback?: string) => Promise<IPCResult>;
|
||||
submitReview: (taskId: string, approved: boolean, feedback?: string, images?: ImageAttachment[]) => Promise<IPCResult>;
|
||||
updateTaskStatus: (taskId: string, status: TaskStatus, options?: { forceCleanup?: boolean }) => Promise<IPCResult & { worktreeExists?: boolean; worktreePath?: string }>;
|
||||
recoverStuckTask: (taskId: string, options?: TaskRecoveryOptions) => Promise<IPCResult<TaskRecoveryResult>>;
|
||||
checkTaskRunning: (taskId: string) => Promise<IPCResult<boolean>>;
|
||||
|
||||
Reference in New Issue
Block a user