Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/frontend/src/__tests__/integration/ipc-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,15 +148,17 @@ 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');

expect(mockIpcRenderer.invoke).toHaveBeenCalledWith(
'task:review',
'task-id',
false,
'Needs more work'
'Needs more work',
undefined
);
});
});
Expand Down
64 changes: 60 additions & 4 deletions apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider using async file operations to avoid blocking the event loop.

writeFileSync is used inside an async IPC handler. For potentially large image files, this could block the main process event loop.

♻️ Suggested async approach
+import { promises as fsPromises } from 'fs';

// In the image processing loop:
-              writeFileSync(imagePath, imageBuffer);
+              await fsPromises.writeFile(imagePath, imageBuffer);

Similarly, mkdirSync at line 417 could use await fsPromises.mkdir(imagesDir, { recursive: true }).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
writeFileSync(imagePath, imageBuffer);
await fsPromises.writeFile(imagePath, imageBuffer);
🤖 Prompt for AI Agents
In @apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts at line 430,
Replace the blocking calls writeFileSync and mkdirSync inside the async IPC
handler with their async fs.promises equivalents: use await
fs.promises.mkdir(imagesDir, { recursive: true }) (replacing mkdirSync) and
await fs.promises.writeFile(imagePath, imageBuffer) (replacing writeFileSync);
ensure the handler function is async (it already is) and import/alias
fs.promises (or fsPromises) at the top so you await these operations and
handle/rethrow errors as appropriate around the imagePath/imageBuffer/imagesDir
logic.

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);
}
}
Comment on lines +453 to +456

This comment was marked as outdated.

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);
Expand Down
11 changes: 7 additions & 4 deletions apps/frontend/src/preload/api/task-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import type {
SupportedIDE,
SupportedTerminal,
WorktreeCreatePROptions,
WorktreeCreatePRResult
WorktreeCreatePRResult,
ImageAttachment
} from '../../shared/types';

export interface TaskAPI {
Expand All @@ -35,7 +36,8 @@ export interface TaskAPI {
submitReview: (
taskId: string,
approved: boolean,
feedback?: string
feedback?: string,
images?: ImageAttachment[]
) => Promise<IPCResult>;
updateTaskStatus: (
taskId: string,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Form state cleared regardless of submission success

Medium Severity

The handleReject function clears feedbackImages (and feedback) unconditionally after calling submitReview, without checking whether submission succeeded. The submitReview function returns false on failure, but this return value is ignored. If submission fails, users lose their feedback text and attached images with no error indication. While text clearing was pre-existing, the new setFeedbackImages([]) call extends this data loss to images, which can be harder to recreate (e.g., screenshots). Compare with handleDelete in the same file which correctly checks result.success before cleanup.

Fix in Cursor Fix in Web

};

const handleDelete = async () => {
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -81,6 +85,8 @@ export function TaskReview({
showConflictDialog,
onFeedbackChange,
onReject,
images,
onImagesChange,
onMerge,
onDiscard,
onShowDiscardDialog,
Expand Down Expand Up @@ -157,6 +163,8 @@ export function TaskReview({
isSubmitting={isSubmitting}
onFeedbackChange={onFeedbackChange}
onReject={onReject}
images={images}
onImagesChange={onImagesChange}
/>

{/* Discard Confirmation Dialog */}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -50,6 +50,7 @@ export interface UseTaskDetailOptions {

export function useTaskDetail({ task }: UseTaskDetailOptions) {
const [feedback, setFeedback] = useState('');
const [feedbackImages, setFeedbackImages] = useState<ImageAttachment[]>([]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider resetting feedbackImages when the task changes.

The feedbackImages state is not reset when the task prop changes. If the modal is reused across different tasks without unmounting, stale images from a previous review could persist.

♻️ Suggested defensive reset

Add an effect to clear images when the task ID changes:

+ // Reset feedback images when task changes
+ useEffect(() => {
+   setFeedbackImages([]);
+ }, [task.id]);
🤖 Prompt for AI Agents
In @apps/frontend/src/renderer/components/task-detail/hooks/useTaskDetail.ts at
line 12, The feedbackImages state in useTaskDetail is never cleared when the
task prop changes, causing stale images to persist; add a useEffect inside the
useTaskDetail hook that watches the task identifier (e.g., task?.id or taskId)
and calls setFeedbackImages([]) whenever it changes so the modal resets images
when a new task is loaded. Ensure the effect runs only on task id changes (not
on every render) and reference feedbackImages and setFeedbackImages within the
useTaskDetail hook to implement the reset.

const [isSubmitting, setIsSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState('overview');
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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([]);
}, []);
Comment on lines +264 to +282
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These new helper functions (addFeedbackImage, addFeedbackImages, removeFeedbackImage, clearFeedbackImages) for manipulating the feedbackImages state are a good addition. However, they are not currently used in this pull request. The QAFeedbackSection component receives the raw setFeedbackImages setter and implements its own logic for adding and removing images.

To improve encapsulation and make the useTaskDetail hook the single source of truth for this state logic, you should refactor the code to use these functions. Pass them as props to QAFeedbackSection instead of setFeedbackImages. This would make the code cleaner and avoid re-implementing state update logic in the component.

For example, QAFeedbackSection could receive onAddImages={addFeedbackImages} and onRemoveImage={removeFeedbackImage}.


// Track if we've already loaded preview for this task to prevent infinite loops
const hasLoadedPreviewRef = useRef<string | null>(null);

Expand Down Expand Up @@ -404,6 +430,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
return {
// State
feedback,
feedbackImages,
isSubmitting,
activeTab,
isUserScrolledUp,
Expand Down Expand Up @@ -447,6 +474,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {

// Setters
setFeedback,
setFeedbackImages,
setIsSubmitting,
setActiveTab,
setIsUserScrolledUp,
Expand Down Expand Up @@ -482,6 +510,10 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
handleLogsScroll,
togglePhase,
loadMergePreview,
addFeedbackImage,
addFeedbackImages,
removeFeedbackImage,
clearFeedbackImages,
handleReviewAgain,
reloadPlanForIncompleteTask,
};
Expand Down
Loading
Loading