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:
Andy
2026-01-13 21:47:37 +01:00
committed by AndyMik90
parent 17118b0711
commit 88277f843f
11 changed files with 473 additions and 28 deletions
@@ -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 => `![Feedback Image](${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);
+7 -4
View File
@@ -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,
};
@@ -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."
},
+3 -2
View File
@@ -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>>;