diff --git a/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts index 98d2bda3..16c85263 100644 --- a/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts @@ -1,4 +1,4 @@ -import { ipcMain } from 'electron'; +import { ipcMain, nativeImage } from 'electron'; import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/constants'; import type { IPCResult, Task, TaskMetadata } from '../../../shared/types'; import path from 'path'; @@ -7,7 +7,8 @@ import { projectStore } from '../../project-store'; import { titleGenerator } from '../../title-generator'; import { AgentManager } from '../../agent'; import { findTaskAndProject } from './shared'; -import { findAllSpecPaths } from '../../utils/spec-path-helpers'; +import { findAllSpecPaths, isValidTaskId } from '../../utils/spec-path-helpers'; +import { isPathWithinBase } from '../../worktree-paths'; /** * Register task CRUD (Create, Read, Update, Delete) handlers @@ -467,4 +468,95 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void { } } ); + + /** + * Load an image thumbnail from disk + * Used to load thumbnails for images that were saved without base64 data + * @param projectPath - The project root path + * @param specId - The spec ID + * @param imagePath - Relative path to the image (e.g., 'attachments/image.png') + * @returns Base64 data URL thumbnail + */ + ipcMain.handle( + IPC_CHANNELS.TASK_LOAD_IMAGE_THUMBNAIL, + async ( + _, + projectPath: string, + specId: string, + imagePath: string + ): Promise> => { + try { + // Validate specId to prevent path traversal attacks + if (!isValidTaskId(specId)) { + console.error(`[IPC] TASK_LOAD_IMAGE_THUMBNAIL: Invalid specId rejected: "${specId}"`); + return { success: false, error: 'Invalid spec ID' }; + } + + // Get project to determine auto-build path - validate projectPath exists + const projects = projectStore.getProjects(); + const project = projects.find((p) => p.path === projectPath); + if (!project) { + console.error(`[IPC] TASK_LOAD_IMAGE_THUMBNAIL: Unknown project: "${projectPath}"`); + return { success: false, error: 'Unknown project' }; + } + const autoBuildPath = project.autoBuildPath || '.auto-claude'; + + // Build full path to the image + const specsDir = getSpecsDir(autoBuildPath); + const fullImagePath = path.join(projectPath, specsDir, specId, imagePath); + + // Validate path to prevent path traversal attacks + const expectedBase = path.resolve(path.join(projectPath, specsDir, specId)); + const resolvedPath = path.resolve(fullImagePath); + if (!isPathWithinBase(resolvedPath, expectedBase)) { + console.error(`[IPC] Path traversal detected: imagePath "${imagePath}" resolves outside spec directory`); + return { success: false, error: 'Invalid image path' }; + } + + if (!existsSync(fullImagePath)) { + return { success: false, error: `Image not found: ${imagePath}` }; + } + + // Load image using nativeImage + const image = nativeImage.createFromPath(fullImagePath); + if (image.isEmpty()) { + return { success: false, error: 'Failed to load image' }; + } + + // Get original size + const size = image.getSize(); + const maxSize = 200; + + // Calculate thumbnail dimensions while maintaining aspect ratio + let width = size.width; + let height = size.height; + if (width > height) { + if (width > maxSize) { + height = Math.round((height * maxSize) / width); + width = maxSize; + } + } else { + if (height > maxSize) { + width = Math.round((width * maxSize) / height); + height = maxSize; + } + } + + // Resize to thumbnail + const thumbnail = image.resize({ width, height, quality: 'good' }); + + // Convert to base64 data URL + // Use JPEG for thumbnails (smaller size, good for previews) + const base64 = thumbnail.toJPEG(80).toString('base64'); + const dataUrl = `data:image/jpeg;base64,${base64}`; + + return { success: true, data: dataUrl }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error loading thumbnail' + }; + } + } + ); } diff --git a/apps/frontend/src/main/worktree-paths.ts b/apps/frontend/src/main/worktree-paths.ts index 3716e070..c78e6ea5 100644 --- a/apps/frontend/src/main/worktree-paths.ts +++ b/apps/frontend/src/main/worktree-paths.ts @@ -36,7 +36,7 @@ export function getTaskWorktreePath(projectPath: string, specId: string): string * Validate that a resolved path is within the expected base directory * Protects against path traversal attacks (e.g., specId containing "..") */ -function isPathWithinBase(resolvedPath: string, basePath: string): boolean { +export function isPathWithinBase(resolvedPath: string, basePath: string): boolean { const normalizedPath = path.resolve(resolvedPath); const normalizedBase = path.resolve(basePath); return normalizedPath.startsWith(normalizedBase + path.sep) || normalizedPath === normalizedBase; diff --git a/apps/frontend/src/preload/api/task-api.ts b/apps/frontend/src/preload/api/task-api.ts index f00cbea2..50fcefd5 100644 --- a/apps/frontend/src/preload/api/task-api.ts +++ b/apps/frontend/src/preload/api/task-api.ts @@ -50,6 +50,9 @@ export interface TaskAPI { ) => Promise>; checkTaskRunning: (taskId: string) => Promise>; + // Image Operations + loadImageThumbnail: (projectPath: string, specId: string, imagePath: string) => Promise>; + // Workspace Management (for human review) getWorktreeStatus: (taskId: string) => Promise>; getWorktreeDiff: (taskId: string) => Promise>; @@ -135,6 +138,10 @@ export const createTaskAPI = (): TaskAPI => ({ checkTaskRunning: (taskId: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.TASK_CHECK_RUNNING, taskId), + // Image Operations + loadImageThumbnail: (projectPath: string, specId: string, imagePath: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TASK_LOAD_IMAGE_THUMBNAIL, projectPath, specId, imagePath), + // Workspace Management getWorktreeStatus: (taskId: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_STATUS, taskId), diff --git a/apps/frontend/src/renderer/components/TaskEditDialog.tsx b/apps/frontend/src/renderer/components/TaskEditDialog.tsx index 65c4961b..59e83f39 100644 --- a/apps/frontend/src/renderer/components/TaskEditDialog.tsx +++ b/apps/frontend/src/renderer/components/TaskEditDialog.tsx @@ -26,7 +26,7 @@ * /> * ``` */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Loader2 } from 'lucide-react'; import { Button } from './ui/button'; @@ -34,6 +34,7 @@ import { TaskModalLayout } from './task-form/TaskModalLayout'; import { TaskFormFields } from './task-form/TaskFormFields'; import { type FileReferenceData } from './task-form/useImageUpload'; import { persistUpdateTask } from '../stores/task-store'; +import { useProjectStore } from '../stores/project-store'; import type { Task, ImageAttachment, TaskCategory, TaskPriority, TaskComplexity, TaskImpact, ModelType, ThinkingLevel } from '../../shared/types'; import { DEFAULT_AGENT_PROFILES, @@ -65,6 +66,13 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi p => p.id === settings.selectedAgentProfile ) || DEFAULT_AGENT_PROFILES.find(p => p.id === 'auto')!; + // Get project path for loading image thumbnails from disk + const projects = useProjectStore((state) => state.projects); + const projectPath = useMemo(() => { + const project = projects.find(p => p.id === task.projectId); + return project?.path; + }, [projects, task.projectId]); + // Form state const [title, setTitle] = useState(task.title); const [description, setDescription] = useState(task.description); @@ -269,6 +277,8 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi } > void; + image: ImageAttachment | null; +} + +export function ImagePreviewModal({ open, onOpenChange, image }: ImagePreviewModalProps) { + const { t } = useTranslation(['tasks', 'common']); + + if (!image) return null; + + // Determine the image source - prefer full-resolution data for enlarged preview, fall back to thumbnail + const imageSrc = image.data ? `data:${image.mimeType};base64,${image.data}` : image.thumbnail || null; + const isThumbnailFallback = !image.data && image.thumbnail; + + return ( + + + {/* Overlay with dark background and backdrop blur */} + + + {/* Content container */} + + {/* Header with title and close button */} +
+ + {image.filename} + + + + {t('tasks:imagePreview.close')} + +
+ + {/* Image display */} +
+ {imageSrc ? ( + <> + {image.filename} + {/* Show indicator when displaying thumbnail fallback */} + {isThumbnailFallback && ( +
+

{t('tasks:imagePreview.lowResolution')}

+
+ )} + + ) : ( + // Fallback when no image data is available +
+ +

{t('tasks:imagePreview.unavailable')}

+
+ )} +
+ + {/* Hidden description for accessibility */} + + {t('tasks:imagePreview.description', { filename: image.filename })} + +
+
+
+ ); +} diff --git a/apps/frontend/src/renderer/components/task-form/TaskFormFields.tsx b/apps/frontend/src/renderer/components/task-form/TaskFormFields.tsx index 7fde91bc..443c6b9b 100644 --- a/apps/frontend/src/renderer/components/task-form/TaskFormFields.tsx +++ b/apps/frontend/src/renderer/components/task-form/TaskFormFields.tsx @@ -3,10 +3,10 @@ * * Bundles the common form fields used in both TaskCreationWizard and TaskEditDialog: * - Description (required, with image paste/drop support) + * - Reference Images section (collapsible, with screenshot capture) * - Title (optional) * - Agent profile selector * - Classification fields (collapsible) - * - Reference Images section (collapsible, with screenshot capture) * - Review requirement checkbox */ import { useRef, useState, useEffect, type ReactNode } from 'react'; @@ -22,6 +22,7 @@ import { ClassificationFields } from './ClassificationFields'; import { useImageUpload, type FileReferenceData } from './useImageUpload'; import { createThumbnail } from '../ImageUpload'; import { ScreenshotCapture } from '../ScreenshotCapture'; +import { ImagePreviewModal } from './ImagePreviewModal'; import { cn } from '../../lib/utils'; import { MAX_IMAGES_PER_TASK } from '../../../shared/constants'; import type { @@ -36,6 +37,10 @@ import type { import type { PhaseModelConfig, PhaseThinkingConfig } from '../../../shared/types/settings'; interface TaskFormFieldsProps { + // Project context (for loading image thumbnails from disk) + projectPath?: string; + specId?: string; + // Description field description: string; onDescriptionChange: (value: string) => void; @@ -97,6 +102,8 @@ interface TaskFormFieldsProps { } export function TaskFormFields({ + projectPath, + specId, description, onDescriptionChange, descriptionPlaceholder, @@ -144,6 +151,7 @@ export function TaskFormFields({ // Reference Images section state const [showReferenceImages, setShowReferenceImages] = useState(false); const [screenshotModalOpen, setScreenshotModalOpen] = useState(false); + const [previewImage, setPreviewImage] = useState(null); // Auto-expand reference images section when images are added via paste/drop/capture const prevImagesLengthRef = useRef(images.length); @@ -155,6 +163,68 @@ export function TaskFormFields({ prevImagesLengthRef.current = images.length; }, [images.length]); + // Track images we've attempted to load thumbnails for to prevent infinite loops + // Note: Failed thumbnail loads are not retried (persists across re-renders) + // This prevents repeated failed IPC calls for missing/corrupt images + const loadedThumbnailsRef = useRef>(new Set()); + + // Track the latest images to avoid stale closure issues + const imagesRef = useRef(images); + useEffect(() => { + imagesRef.current = images; + }, [images]); + + // Load thumbnails for images that have path but no thumbnail (fix placeholder bug) + // This handles the case when TaskFormFields mounts with persisted images from disk + useEffect(() => { + let cancelled = false; + + const loadMissingThumbnails = async () => { + // Need project context to load images from disk + if (!projectPath || !specId) return; + + // Find images that have path but no thumbnail and haven't been attempted yet + const imagesToLoad = images.filter( + img => img.path && !img.thumbnail && !loadedThumbnailsRef.current.has(img.id) + ); + + if (imagesToLoad.length === 0) return; + + // Mark these as attempted before loading to prevent re-entry + imagesToLoad.forEach(img => loadedThumbnailsRef.current.add(img.id)); + + // Collect loaded thumbnails into a Map to avoid stale closure issues + const thumbnailMap = new Map(); + + for (const image of imagesToLoad) { + try { + const result = await window.electronAPI.loadImageThumbnail(projectPath, specId, image.path!); + if (result.success && result.data) { + thumbnailMap.set(image.id, result.data); + } + } catch (error) { + // Log for debugging but don't block other images + console.debug('Failed to load thumbnail for image', image.id, error); + } + } + + // Merge thumbnails into current state without overwriting user changes + if (thumbnailMap.size > 0 && !cancelled) { + const updatedImages = imagesRef.current.map(img => ({ + ...img, + thumbnail: thumbnailMap.get(img.id) ?? img.thumbnail + })); + onImagesChange(updatedImages); + } + }; + + loadMissingThumbnails(); + + return () => { + cancelled = true; + }; + }, [images, onImagesChange, projectPath, specId]); + // Use the shared image upload hook with translated error messages const { isDragOver, @@ -215,6 +285,11 @@ export function TaskFormFields({ onOpenChange={setScreenshotModalOpen} onCapture={handleScreenshotCapture} /> + !open && setPreviewImage(null)} + image={previewImage} + />
{/* Description (Primary - Required) */} @@ -263,38 +338,6 @@ export function TaskFormFields({
)} - {/* Title (Optional) */} -
- - onTitleChange(e.target.value)} - disabled={disabled} - /> -

- {t('tasks:form.titleHelpText')} -

-
- - {/* Agent Profile Selection */} - - {/* Reference Images Toggle */}