Move Reference Images Above Task Title & Fix Image Display Issues (#1513)
* auto-claude: subtask-1-1 - Add TASK_LOAD_IMAGE_THUMBNAIL IPC channel constant * auto-claude: subtask-1-2 - Add loadImageThumbnail IPC handler in crud-handlers Add TASK_LOAD_IMAGE_THUMBNAIL handler that: - Reads image from disk using Electron's nativeImage - Creates thumbnail maintaining aspect ratio (max 200px) - Returns base64 JPEG data URL for efficient preview display This handler enables loading thumbnails for images stored on disk without base64 data (only path reference stored in metadata). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-1-3 - Expose loadImageThumbnail API in task-api.ts preload * auto-claude: subtask-2-1 - Create ImagePreviewModal.tsx component using Radix Add modal component for displaying enlarged image previews on double-click: - Uses Radix Dialog primitives for accessibility (Escape to close) - Dark semi-transparent overlay with backdrop blur - Close button in top-right corner - Image maintains aspect ratio with object-contain - Displays filename in modal title - Fallback UI when image data unavailable - Full i18n support for translations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-2-2 - Add imagePreview translation keys to English tasks Add imagePreview translation keys to tasks.json: - close: For close button aria-label - unavailable: Fallback when image data not available - description: Accessibility description with filename interpolation - doubleClickHint: Hint text for image thumbnails * auto-claude: subtask-2-3 - Add imagePreview translation keys to French tasks.json * auto-claude: subtask-3-1 - Reorder JSX in TaskFormFields.tsx to move Reference Images above Title * auto-claude: subtask-3-2 - Add image preview state and ImagePreviewModal to TaskFormFields * auto-claude: subtask-3-4 - Add thumbnail loading for images with path but no thumbnail Fix the placeholder bug where images saved with file paths display as placeholder icons instead of actual thumbnails when reopening tasks. - Add useEffect in TaskFormFields that detects images with path but no thumbnail - Load thumbnails via loadImageThumbnail IPC call when component mounts - Use ref to track attempted loads and prevent infinite loops - Add loadImageThumbnail to ElectronAPI interface in shared types - Add browser mock implementation for loadImageThumbnail Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-4-3 - Fix placeholder bug by passing project context to loadImageThumbnail The loadImageThumbnail IPC handler requires 3 parameters (projectPath, specId, imagePath) but the API and components were only passing 1. This fix: - Updates TaskFormFields to accept optional projectPath and specId props - Updates the loadImageThumbnail API to pass all 3 required parameters - Updates TaskEditDialog to retrieve projectPath from project store and pass it - Fixes return type mismatch (IPCResult<string> instead of IPCResult<{thumbnail}>) - Updates mock to match new signature This enables proper thumbnail loading when reopening tasks with saved images. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Security and bug fixes for image handling This commit addresses all critical and high-priority findings from the PR review: 1. CRITICAL - Path traversal vulnerability (apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts) - Added path traversal validation using isPathWithinBase() function - Prevents arbitrary file access via malicious imagePath parameters - Validates that resolved paths stay within the expected spec directory 2. HIGH - Stale closure causing lost user changes (apps/frontend/src/renderer/components/task-form/TaskFormFields.tsx) - Fixed by using ref to track latest images state - Thumbnails now merge into current state without overwriting concurrent user changes - Prevents race condition when user adds/removes images during async loading 3. MEDIUM - Missing cleanup for async useEffect (apps/frontend/src/renderer/components/task-form/TaskFormFields.tsx) - Added cleanup function with cancelled flag - Prevents state updates after component unmount - Eliminates React warnings and potential memory leaks 4. MEDIUM - Image preview shows thumbnail instead of full-resolution (apps/frontend/src/renderer/components/task-form/ImagePreviewModal.tsx) - Reversed priority to prefer full-resolution data over thumbnail - Users now see high-quality images in the enlarged preview modal 5. LOW - Silent catch block (apps/frontend/src/renderer/components/task-form/TaskFormFields.tsx) - Added console.debug logging for thumbnail load failures - Improves debugging without disrupting user experience Additional changes: - Exported isPathWithinBase() from worktree-paths.ts for reuse in other handlers Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: Address follow-up security findings and UX improvements This commit addresses all findings from the follow-up PR review: **Blocking Issues Fixed:** 1. HIGH - specId path traversal allows bypassing path validation - Added validation using isValidTaskId() to reject specIds with path traversal sequences - Prevents attackers from setting expectedBase to malicious locations via '../' in specId - Now validates specId format before constructing any paths 2. MEDIUM - Handler proceeds with unvalidated projectPath when project not found - Added check to return error if project is not found in projectStore - Prevents file operations from arbitrary directories - Only allows loading images from registered projects **Low Priority Improvements:** 3. LOW - Document loadedThumbnailsRef behavior - Added comment explaining that failed thumbnail loads are not retried - Clarifies intentional behavior to prevent repeated failed IPC calls 4. LOW - Visual indicator for thumbnail fallback - Added "Low resolution preview" badge when displaying 200px thumbnail - Added translation keys for both English and French - Improves UX by informing users when full-resolution is unavailable All changes maintain backward compatibility and improve security posture. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<IPCResult<string>> => {
|
||||
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'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -50,6 +50,9 @@ export interface TaskAPI {
|
||||
) => Promise<IPCResult<TaskRecoveryResult>>;
|
||||
checkTaskRunning: (taskId: string) => Promise<IPCResult<boolean>>;
|
||||
|
||||
// Image Operations
|
||||
loadImageThumbnail: (projectPath: string, specId: string, imagePath: string) => Promise<IPCResult<string>>;
|
||||
|
||||
// Workspace Management (for human review)
|
||||
getWorktreeStatus: (taskId: string) => Promise<IPCResult<import('../../shared/types').WorktreeStatus>>;
|
||||
getWorktreeDiff: (taskId: string) => Promise<IPCResult<import('../../shared/types').WorktreeDiff>>;
|
||||
@@ -135,6 +138,10 @@ export const createTaskAPI = (): TaskAPI => ({
|
||||
checkTaskRunning: (taskId: string): Promise<IPCResult<boolean>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_CHECK_RUNNING, taskId),
|
||||
|
||||
// Image Operations
|
||||
loadImageThumbnail: (projectPath: string, specId: string, imagePath: string): Promise<IPCResult<string>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_LOAD_IMAGE_THUMBNAIL, projectPath, specId, imagePath),
|
||||
|
||||
// Workspace Management
|
||||
getWorktreeStatus: (taskId: string): Promise<IPCResult<import('../../shared/types').WorktreeStatus>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_STATUS, taskId),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
>
|
||||
<TaskFormFields
|
||||
projectPath={projectPath}
|
||||
specId={task.specId}
|
||||
description={description}
|
||||
onDescriptionChange={setDescription}
|
||||
title={title}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* ImagePreviewModal - Modal for displaying enlarged image previews
|
||||
*
|
||||
* Displays a full-screen preview of an image when double-clicked in the task form.
|
||||
* Uses Radix Dialog primitives for accessibility and keyboard support (Escape to close).
|
||||
*
|
||||
* Features:
|
||||
* - Dark semi-transparent overlay with backdrop blur
|
||||
* - Close button in top-right corner
|
||||
* - Image maintains aspect ratio with object-contain
|
||||
* - Closes on Escape key press (built-in Radix behavior)
|
||||
* - Displays filename in modal title
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X, ImageIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { ImageAttachment } from '../../../shared/types';
|
||||
|
||||
interface ImagePreviewModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => 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 (
|
||||
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPrimitive.Portal>
|
||||
{/* Overlay with dark background and backdrop blur */}
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 backdrop-blur-sm',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Content container */}
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
'fixed inset-8 z-50 flex flex-col items-center justify-center',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'duration-200'
|
||||
)}
|
||||
>
|
||||
{/* Header with title and close button */}
|
||||
<div className="absolute top-0 left-0 right-0 flex items-center justify-between p-4">
|
||||
<DialogPrimitive.Title className="text-lg font-medium text-white truncate max-w-[calc(100%-60px)]">
|
||||
{image.filename}
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Close
|
||||
className={cn(
|
||||
'rounded-lg p-2',
|
||||
'text-white/70 hover:text-white',
|
||||
'hover:bg-white/10 transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-white/50',
|
||||
'disabled:pointer-events-none'
|
||||
)}
|
||||
aria-label={t('tasks:imagePreview.close')}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<span className="sr-only">{t('tasks:imagePreview.close')}</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
|
||||
{/* Image display */}
|
||||
<div className="flex flex-col items-center justify-center w-full h-full p-8 gap-4">
|
||||
{imageSrc ? (
|
||||
<>
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={image.filename}
|
||||
className="max-w-full max-h-full object-contain rounded-lg shadow-2xl"
|
||||
/>
|
||||
{/* Show indicator when displaying thumbnail fallback */}
|
||||
{isThumbnailFallback && (
|
||||
<div className="px-3 py-1 rounded-full bg-white/10 backdrop-blur-sm">
|
||||
<p className="text-xs text-white/70">{t('tasks:imagePreview.lowResolution')}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Fallback when no image data is available
|
||||
<div className="flex flex-col items-center justify-center text-white/50">
|
||||
<ImageIcon className="h-24 w-24 mb-4" />
|
||||
<p className="text-sm">{t('tasks:imagePreview.unavailable')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hidden description for accessibility */}
|
||||
<DialogPrimitive.Description className="sr-only">
|
||||
{t('tasks:imagePreview.description', { filename: image.filename })}
|
||||
</DialogPrimitive.Description>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
@@ -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<ImageAttachment | null>(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<Set<string>>(new Set());
|
||||
|
||||
// Track the latest images to avoid stale closure issues
|
||||
const imagesRef = useRef<ImageAttachment[]>(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<string, string>();
|
||||
|
||||
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}
|
||||
/>
|
||||
<ImagePreviewModal
|
||||
open={previewImage !== null}
|
||||
onOpenChange={(open) => !open && setPreviewImage(null)}
|
||||
image={previewImage}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Description (Primary - Required) */}
|
||||
@@ -263,38 +338,6 @@ export function TaskFormFields({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title (Optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}title`} className="text-sm font-medium text-foreground">
|
||||
{t('tasks:form.taskTitle')} <span className="text-muted-foreground font-normal">({t('common:labels.optional')})</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={`${prefix}title`}
|
||||
placeholder={t('tasks:form.titlePlaceholder')}
|
||||
value={title}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('tasks:form.titleHelpText')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Agent Profile Selection */}
|
||||
<AgentProfileSelector
|
||||
profileId={profileId}
|
||||
model={model}
|
||||
thinkingLevel={thinkingLevel}
|
||||
phaseModels={phaseModels}
|
||||
phaseThinking={phaseThinking}
|
||||
onProfileChange={onProfileChange}
|
||||
onModelChange={onModelChange}
|
||||
onThinkingLevelChange={onThinkingLevelChange}
|
||||
onPhaseModelsChange={onPhaseModelsChange}
|
||||
onPhaseThinkingChange={onPhaseThinkingChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Reference Images Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -356,6 +399,7 @@ export function TaskFormFields({
|
||||
className="relative group rounded-md border border-border overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
|
||||
style={{ width: '72px', height: '72px' }}
|
||||
title={image.filename}
|
||||
onDoubleClick={() => setPreviewImage(image)}
|
||||
>
|
||||
{image.thumbnail ? (
|
||||
<img
|
||||
@@ -397,6 +441,38 @@ export function TaskFormFields({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title (Optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}title`} className="text-sm font-medium text-foreground">
|
||||
{t('tasks:form.taskTitle')} <span className="text-muted-foreground font-normal">({t('common:labels.optional')})</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={`${prefix}title`}
|
||||
placeholder={t('tasks:form.titlePlaceholder')}
|
||||
value={title}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('tasks:form.titleHelpText')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Agent Profile Selection */}
|
||||
<AgentProfileSelector
|
||||
profileId={profileId}
|
||||
model={model}
|
||||
thinkingLevel={thinkingLevel}
|
||||
phaseModels={phaseModels}
|
||||
phaseThinking={phaseThinking}
|
||||
onProfileChange={onProfileChange}
|
||||
onModelChange={onModelChange}
|
||||
onThinkingLevelChange={onThinkingLevelChange}
|
||||
onPhaseModelsChange={onPhaseModelsChange}
|
||||
onPhaseThinkingChange={onPhaseThinkingChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Classification Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -74,6 +74,12 @@ export const taskMock = {
|
||||
|
||||
checkTaskRunning: async () => ({ success: true, data: false }),
|
||||
|
||||
// Image operations
|
||||
loadImageThumbnail: async (_projectPath: string, _specId: string, _imagePath: string) => ({
|
||||
success: false,
|
||||
error: 'Image loading not available in browser mode'
|
||||
}),
|
||||
|
||||
// Task logs operations
|
||||
getTaskLogs: async () => ({
|
||||
success: true,
|
||||
|
||||
@@ -27,6 +27,7 @@ export const IPC_CHANNELS = {
|
||||
TASK_UPDATE_STATUS: 'task:updateStatus',
|
||||
TASK_RECOVER_STUCK: 'task:recoverStuck',
|
||||
TASK_CHECK_RUNNING: 'task:checkRunning',
|
||||
TASK_LOAD_IMAGE_THUMBNAIL: 'task:loadImageThumbnail',
|
||||
|
||||
// Workspace management (for human review)
|
||||
// Per-spec architecture: Each spec has its own worktree at .worktrees/{spec-name}/
|
||||
|
||||
@@ -171,6 +171,13 @@
|
||||
"removeImageAriaLabel": "Remove image {{filename}}",
|
||||
"pasteHint": "Tip: Paste screenshots directly with {{shortcut}} to add reference images."
|
||||
},
|
||||
"imagePreview": {
|
||||
"close": "Close preview",
|
||||
"unavailable": "Image unavailable",
|
||||
"description": "Preview of {{filename}}",
|
||||
"doubleClickHint": "Double-click to enlarge",
|
||||
"lowResolution": "Low resolution preview"
|
||||
},
|
||||
"notifications": {
|
||||
"backgroundTaskTitle": "Task continues in background",
|
||||
"backgroundTaskDescription": "The task is still running. You can reopen this dialog to monitor progress."
|
||||
|
||||
@@ -170,6 +170,13 @@
|
||||
"removeImageAriaLabel": "Supprimer l'image {{filename}}",
|
||||
"pasteHint": "Astuce : Collez des captures d'écran directement avec {{shortcut}} pour ajouter des images de référence."
|
||||
},
|
||||
"imagePreview": {
|
||||
"close": "Fermer l'aperçu",
|
||||
"unavailable": "Image indisponible",
|
||||
"description": "Aperçu de {{filename}}",
|
||||
"doubleClickHint": "Double-cliquez pour agrandir",
|
||||
"lowResolution": "Aperçu basse résolution"
|
||||
},
|
||||
"notifications": {
|
||||
"backgroundTaskTitle": "La tâche continue en arrière-plan",
|
||||
"backgroundTaskDescription": "La tâche est toujours en cours. Vous pouvez rouvrir cette boîte de dialogue pour suivre la progression."
|
||||
|
||||
@@ -169,6 +169,9 @@ export interface ElectronAPI {
|
||||
recoverStuckTask: (taskId: string, options?: TaskRecoveryOptions) => Promise<IPCResult<TaskRecoveryResult>>;
|
||||
checkTaskRunning: (taskId: string) => Promise<IPCResult<boolean>>;
|
||||
|
||||
// Image operations
|
||||
loadImageThumbnail: (projectPath: string, specId: string, imagePath: string) => Promise<IPCResult<string>>;
|
||||
|
||||
// Workspace management (for human review)
|
||||
// Per-spec architecture: Each spec has its own worktree at .worktrees/{spec-name}/
|
||||
getWorktreeStatus: (taskId: string) => Promise<IPCResult<WorktreeStatus>>;
|
||||
|
||||
Reference in New Issue
Block a user