refactor(ui): extract shared task form components for consistent moda… (#765)

* refactor(ui): extract shared task form components for consistent modal sizing

Create shared components to unify TaskCreationWizard, TaskEditDialog, and
TaskDetailModal with consistent full-height modal sizing.

New shared components in task-form/:
- TaskModalLayout: Full-height modal matching TaskDetailModal (95vw, max-w-5xl)
- TaskFormFields: Common form fields (description, title, profile, classification)
- ClassificationFields: Task classification 2x2 grid dropdowns
- useImageUpload: Hook for image paste/drop handling

Benefits:
- All 3 task modals now have identical dimensions and positioning
- Reduced code duplication (1,938 → 1,651 lines total)
- TaskCreationWizard: 1,176 → 623 lines (47% reduction)
- TaskEditDialog: 762 → 293 lines (62% reduction)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review feedback for task form components

- Fix HIGH: Pass descriptionRef from TaskCreationWizard to TaskFormFields
  to fix broken @ mention autocomplete positioning
- Fix MEDIUM: Add i18n translations for all hardcoded strings in:
  - ClassificationFields.tsx
  - TaskFormFields.tsx
  - TaskModalLayout.tsx
  - TaskCreationWizard.tsx (modal, draft, buttons, git options)
  - TaskEditDialog.tsx
- Fix MEDIUM: Correct isAutoProfile logic to only set true when
  profileId === 'auto' (not for all profiles with phase configs)
- Fix MEDIUM: Update handleAutocompleteSelect signature to accept
  optional fullPath parameter
- Fix LOW: Add proper setTimeout cleanup in useImageUpload.ts
- Fix LOW: Use queueMicrotask instead of setTimeout in
  handleAutocompleteSelect for cursor position restoration
- Fix LOW: Move fetch functions inside useEffect to fix
  exhaustive-deps warning
- Add English and French translations for all new i18n keys

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address all i18n violations and logic bug in task form components

i18n Fixes:
- Replace hardcoded error messages in TaskCreationWizard with translation keys
- Replace hardcoded error messages in TaskEditDialog with translation keys
- Use translated default placeholder in TaskFormFields
- Internationalize classification dropdown labels (category, priority,
  complexity, impact) using translation keys instead of hardcoded constants
- Add errorMessages parameter to useImageUpload hook for i18n support
- Pass translated error messages from TaskFormFields to useImageUpload

Logic Bug Fix:
- Fix image removal persistence in TaskEditDialog - always set attachedImages
  to persist removal when all images are deleted (was only set when length > 0)

Translation Updates:
- Add all missing translation keys to en/tasks.json and fr/tasks.json:
  - form.errors.* (descriptionRequired, maxImagesReached, etc.)
  - form.descriptionPlaceholder
  - form.classification.values.* (all classification option labels)
  - wizard.descriptionPlaceholder, wizard.errors.*
  - edit.errors.*

Other:
- Log image processing errors to console for debugging (CMT-001)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address memory leak and performance issues in task form components

- Add isMounted flag to useEffect in TaskCreationWizard to prevent state
  updates after component unmount (CMT-QUALITY-001)
- Wrap errorMessages merge in useMemo in useImageUpload to prevent
  unnecessary useCallback invalidation on re-renders (CMT-PERF-001)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: include phaseModels and phaseThinking in hasChanges check

TaskEditDialog's hasChanges logic was missing phaseModels and phaseThinking
comparisons, which could cause silent data loss when users only modified
phase configuration without changing other fields.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: preserve phaseModels and phaseThinking when editing non-autoProfile tasks

When editing a task with custom model/thinkingLevel that isn't an autoProfile,
the dialog was resetting phaseModels and phaseThinking to defaults instead of
preserving the task's actual values from metadata. This could cause data loss.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-10 21:05:42 +01:00
committed by GitHub
parent 91bd2401cf
commit df540ec512
9 changed files with 1486 additions and 1398 deletions
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,9 @@
*
* Allows users to modify all task properties including title, description,
* classification fields, images, and review settings.
* Follows the same dialog pattern as TaskCreationWizard for consistency.
*
* Now uses the shared TaskModalLayout for consistent styling with other task modals,
* and TaskFormFields for the form content.
*
* Features:
* - Pre-populates form with current task values
@@ -24,48 +26,15 @@
* />
* ```
*/
import { useState, useEffect, useCallback, useRef, type ClipboardEvent, type DragEvent } from 'react';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, Image as ImageIcon, ChevronDown, ChevronUp, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from './ui/dialog';
import { Loader2 } from 'lucide-react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Textarea } from './ui/textarea';
import { Label } from './ui/label';
import { Checkbox } from './ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from './ui/select';
import {
ImageUpload,
generateImageId,
blobToBase64,
createThumbnail,
isValidImageMimeType,
resolveFilename
} from './ImageUpload';
import { AgentProfileSelector } from './AgentProfileSelector';
import { TaskModalLayout } from './task-form/TaskModalLayout';
import { TaskFormFields } from './task-form/TaskFormFields';
import { persistUpdateTask } from '../stores/task-store';
import { cn } from '../lib/utils';
import type { Task, ImageAttachment, TaskCategory, TaskPriority, TaskComplexity, TaskImpact, ModelType, ThinkingLevel } from '../../shared/types';
import {
TASK_CATEGORY_LABELS,
TASK_PRIORITY_LABELS,
TASK_COMPLEXITY_LABELS,
TASK_IMPACT_LABELS,
MAX_IMAGES_PER_TASK,
ALLOWED_IMAGE_TYPES_DISPLAY,
DEFAULT_AGENT_PROFILES,
DEFAULT_PHASE_MODELS,
DEFAULT_PHASE_THINKING
@@ -88,20 +57,19 @@ interface TaskEditDialogProps {
}
export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDialogProps) {
const { t } = useTranslation('tasks');
const { t } = useTranslation(['tasks', 'common']);
// Get selected agent profile from settings for defaults
const { settings } = useSettingsStore();
const selectedProfile = DEFAULT_AGENT_PROFILES.find(
p => p.id === settings.selectedAgentProfile
) || DEFAULT_AGENT_PROFILES.find(p => p.id === 'auto')!;
// Form state
const [title, setTitle] = useState(task.title);
const [description, setDescription] = useState(task.description);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [showImages, setShowImages] = useState(false);
const [pasteSuccess, setPasteSuccess] = useState(false);
const [showClassification, setShowClassification] = useState(false);
// Classification fields
const [category, setCategory] = useState<TaskCategory | ''>(task.metadata?.category || '');
@@ -111,15 +79,12 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
// Agent profile / model configuration
const [profileId, setProfileId] = useState<string>(() => {
// Check if task uses Auto profile
if (task.metadata?.isAutoProfile) {
return 'auto';
}
// Determine profile ID from task metadata or default to 'auto'
const taskModel = task.metadata?.model;
const taskThinking = task.metadata?.thinkingLevel;
if (taskModel && taskThinking) {
// Check if it matches a known profile
const matchingProfile = DEFAULT_AGENT_PROFILES.find(
p => p.model === taskModel && p.thinkingLevel === taskThinking && !p.isAutoProfile
);
@@ -131,7 +96,6 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel | ''>(
task.metadata?.thinkingLevel || selectedProfile.thinkingLevel
);
// Auto profile - per-phase configuration
const [phaseModels, setPhaseModels] = useState<PhaseModelConfig | undefined>(
task.metadata?.phaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS
);
@@ -147,12 +111,6 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
task.metadata?.requireReviewBeforeCoding ?? false
);
// Ref for the textarea to handle paste events
const descriptionRef = useRef<HTMLTextAreaElement>(null);
// Drag-and-drop state for images over textarea
const [isDragOverTextarea, setIsDragOverTextarea] = useState(false);
// Reset form when task changes or dialog opens
useEffect(() => {
if (open) {
@@ -181,8 +139,8 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
setProfileId(matchingProfile?.id || 'custom');
setModel(taskModel);
setThinkingLevel(taskThinking);
setPhaseModels(DEFAULT_PHASE_MODELS);
setPhaseThinking(DEFAULT_PHASE_THINKING);
setPhaseModels(task.metadata?.phaseModels || DEFAULT_PHASE_MODELS);
setPhaseThinking(task.metadata?.phaseThinking || DEFAULT_PHASE_THINKING);
} else {
setProfileId(settings.selectedAgentProfile || 'auto');
setModel(selectedProfile.model);
@@ -195,199 +153,19 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
setRequireReviewBeforeCoding(task.metadata?.requireReviewBeforeCoding ?? false);
setError(null);
// Auto-expand sections if they have content
// Auto-expand classification if it has content
if (task.metadata?.category || task.metadata?.priority || task.metadata?.complexity || task.metadata?.impact) {
setShowAdvanced(true);
}
// Auto-expand images section if task has images
setShowImages((task.metadata?.attachedImages || []).length > 0);
setPasteSuccess(false);
}
}, [open, task, settings.selectedAgentProfile, selectedProfile.model, selectedProfile.thinkingLevel]);
/**
* Handle paste event for screenshot support
*/
const handlePaste = useCallback(async (e: ClipboardEvent<HTMLTextAreaElement>) => {
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);
setShowClassification(true);
} else {
setShowClassification(false);
}
}
// 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(`Maximum of ${MAX_IMAGES_PER_TASK} images allowed`);
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(`Invalid image type. Allowed: ${ALLOWED_IMAGE_TYPES_DISPLAY}`);
continue;
}
try {
const dataUrl = await blobToBase64(file);
const thumbnail = await createThumbnail(dataUrl);
// Generate filename for pasted images (screenshot-timestamp.ext)
const extension = 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 {
setError('Failed to process pasted image');
}
}
if (newImages.length > 0) {
setImages(prev => [...prev, ...newImages]);
// Auto-expand images section
setShowImages(true);
// Show success feedback
setPasteSuccess(true);
setTimeout(() => setPasteSuccess(false), 2000);
}
}, [images]);
/**
* 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 image files
*/
const handleTextareaDrop = useCallback(
async (e: DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOverTextarea(false);
if (isSaving) 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(`Maximum of ${MAX_IMAGES_PER_TASK} images allowed`);
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(`Invalid image type. Allowed: ${ALLOWED_IMAGE_TYPES_DISPLAY}`);
continue;
}
try {
const dataUrl = await blobToBase64(file);
const thumbnail = await createThumbnail(dataUrl);
// Use original filename or generate one
const baseFilename = file.name || `dropped-image-${Date.now()}.${file.type.split('/')[1] || 'png'}`;
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 {
setError('Failed to process dropped image');
}
}
if (newImages.length > 0) {
setImages(prev => [...prev, ...newImages]);
// Auto-expand images section
setShowImages(true);
// Show success feedback
setPasteSuccess(true);
setTimeout(() => setPasteSuccess(false), 2000);
}
},
[images, isSaving]
);
}, [open, task, settings.selectedAgentProfile, selectedProfile.model, selectedProfile.thinkingLevel, selectedProfile.phaseModels, selectedProfile.phaseThinking]);
const handleSave = async () => {
// Validate input - only description is required
// Validate input
if (!description.trim()) {
setError('Description is required');
setError(t('tasks:form.errors.descriptionRequired'));
return;
}
@@ -404,10 +182,11 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
model !== (task.metadata?.model || '') ||
thinkingLevel !== (task.metadata?.thinkingLevel || '') ||
requireReviewBeforeCoding !== (task.metadata?.requireReviewBeforeCoding ?? false) ||
JSON.stringify(images) !== JSON.stringify(task.metadata?.attachedImages || []);
JSON.stringify(images) !== JSON.stringify(task.metadata?.attachedImages || []) ||
JSON.stringify(phaseModels) !== JSON.stringify(task.metadata?.phaseModels || DEFAULT_PHASE_MODELS) ||
JSON.stringify(phaseThinking) !== JSON.stringify(task.metadata?.phaseThinking || DEFAULT_PHASE_THINKING);
if (!hasChanges) {
// No changes, just close
onOpenChange(false);
return;
}
@@ -423,17 +202,15 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
if (impact) metadataUpdates.impact = impact;
if (model) metadataUpdates.model = model as ModelType;
if (thinkingLevel) metadataUpdates.thinkingLevel = thinkingLevel as ThinkingLevel;
// All profiles now support per-phase configuration
// isAutoProfile indicates task uses phase-specific models/thinking
if (phaseModels && phaseThinking) {
metadataUpdates.isAutoProfile = true;
metadataUpdates.isAutoProfile = profileId === 'auto';
metadataUpdates.phaseModels = phaseModels;
metadataUpdates.phaseThinking = phaseThinking;
}
if (images.length > 0) metadataUpdates.attachedImages = images;
// Always set attachedImages to persist removal when all images are deleted
metadataUpdates.attachedImages = images.length > 0 ? images : [];
metadataUpdates.requireReviewBeforeCoding = requireReviewBeforeCoding;
// Title is optional - if empty, it will be auto-generated by the backend
const success = await persistUpdateTask(task.id, {
title: trimmedTitle,
description: trimmedDescription,
@@ -444,319 +221,77 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
onOpenChange(false);
onSaved?.();
} else {
setError('Failed to update task. Please try again.');
setError(t('tasks:edit.errors.updateFailed'));
}
setIsSaving(false);
};
const handleClose = () => {
if (!isSaving) {
onOpenChange(false);
}
};
// Only description is required - title will be auto-generated if empty
const isValid = description.trim().length > 0;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[550px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-foreground">Edit Task</DialogTitle>
<DialogDescription>
Update task details including title, description, classification, images, and settings. Changes will be saved to the spec files.
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-4">
{/* Description (Primary - Required) */}
<div className="space-y-2">
<Label htmlFor="edit-description" className="text-sm font-medium text-foreground">
Description <span className="text-destructive">*</span>
</Label>
<Textarea
ref={descriptionRef}
id="edit-description"
placeholder="Describe the feature, bug fix, or improvement. Be as specific as possible about requirements, constraints, and expected behavior."
value={description}
onChange={(e) => setDescription(e.target.value)}
onPaste={handlePaste}
onDragOver={handleTextareaDragOver}
onDragLeave={handleTextareaDragLeave}
onDrop={handleTextareaDrop}
rows={5}
disabled={isSaving}
aria-required="true"
aria-describedby="edit-description-help"
className={cn(
isDragOverTextarea && !isSaving && "border-primary bg-primary/5 ring-2 ring-primary/20"
)}
/>
<p id="edit-description-help" className="text-xs text-muted-foreground">
{t('images.pasteHint', { shortcut: navigator.platform.includes('Mac') ? '⌘V' : 'Ctrl+V' })}
</p>
</div>
{/* Title (Optional - Auto-generated if empty) */}
<div className="space-y-2">
<Label htmlFor="edit-title" className="text-sm font-medium text-foreground">
Task Title <span className="text-muted-foreground font-normal">(optional)</span>
</Label>
<Input
id="edit-title"
placeholder="Leave empty to auto-generate from description"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={isSaving}
/>
<p className="text-xs text-muted-foreground">
A short, descriptive title will be generated automatically if left empty.
</p>
</div>
{/* Agent Profile Selection */}
<AgentProfileSelector
profileId={profileId}
model={model}
thinkingLevel={thinkingLevel}
phaseModels={phaseModels}
phaseThinking={phaseThinking}
onProfileChange={(newProfileId, newModel, newThinkingLevel) => {
setProfileId(newProfileId);
setModel(newModel);
setThinkingLevel(newThinkingLevel);
}}
onModelChange={setModel}
onThinkingLevelChange={setThinkingLevel}
onPhaseModelsChange={setPhaseModels}
onPhaseThinkingChange={setPhaseThinking}
disabled={isSaving}
/>
{/* Paste Success Indicator */}
{pasteSuccess && (
<div className="flex items-center gap-2 text-sm text-success animate-in fade-in slide-in-from-top-1 duration-200">
<ImageIcon className="h-4 w-4" />
Image added successfully!
</div>
)}
{/* Advanced Options Toggle */}
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className={cn(
'flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors',
'w-full justify-between py-2 px-3 rounded-md hover:bg-muted/50'
)}
disabled={isSaving}
aria-expanded={showAdvanced}
aria-controls="edit-advanced-options"
>
<span>Classification (optional)</span>
{showAdvanced ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{/* Advanced Options */}
{showAdvanced && (
<div id="edit-advanced-options" className="space-y-4 p-4 rounded-lg border border-border bg-muted/30">
<div className="grid grid-cols-2 gap-4">
{/* Category */}
<div className="space-y-2">
<Label htmlFor="edit-category" className="text-xs font-medium text-muted-foreground">
Category
</Label>
<Select
value={category}
onValueChange={(value) => setCategory(value as TaskCategory)}
disabled={isSaving}
>
<SelectTrigger id="edit-category" className="h-9">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{Object.entries(TASK_CATEGORY_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Priority */}
<div className="space-y-2">
<Label htmlFor="edit-priority" className="text-xs font-medium text-muted-foreground">
Priority
</Label>
<Select
value={priority}
onValueChange={(value) => setPriority(value as TaskPriority)}
disabled={isSaving}
>
<SelectTrigger id="edit-priority" className="h-9">
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent>
{Object.entries(TASK_PRIORITY_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Complexity */}
<div className="space-y-2">
<Label htmlFor="edit-complexity" className="text-xs font-medium text-muted-foreground">
Complexity
</Label>
<Select
value={complexity}
onValueChange={(value) => setComplexity(value as TaskComplexity)}
disabled={isSaving}
>
<SelectTrigger id="edit-complexity" className="h-9">
<SelectValue placeholder="Select complexity" />
</SelectTrigger>
<SelectContent>
{Object.entries(TASK_COMPLEXITY_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Impact */}
<div className="space-y-2">
<Label htmlFor="edit-impact" className="text-xs font-medium text-muted-foreground">
Impact
</Label>
<Select
value={impact}
onValueChange={(value) => setImpact(value as TaskImpact)}
disabled={isSaving}
>
<SelectTrigger id="edit-impact" className="h-9">
<SelectValue placeholder="Select impact" />
</SelectTrigger>
<SelectContent>
{Object.entries(TASK_IMPACT_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">
These labels help organize and prioritize tasks. They&apos;re optional but useful for filtering.
</p>
</div>
)}
{/* Images Toggle */}
<button
type="button"
onClick={() => setShowImages(!showImages)}
className={cn(
'flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors',
'w-full justify-between py-2 px-3 rounded-md hover:bg-muted/50'
)}
disabled={isSaving}
aria-expanded={showImages}
aria-controls="edit-images-section"
>
<span className="flex items-center gap-2">
<ImageIcon className="h-4 w-4" />
Reference Images (optional)
{images.length > 0 && (
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">
{images.length}
</span>
)}
</span>
{showImages ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{/* Image Upload Section */}
{showImages && (
<div id="edit-images-section" className="space-y-3 p-4 rounded-lg border border-border bg-muted/30">
<p className="text-xs text-muted-foreground">
Attach screenshots, mockups, or diagrams to provide visual context for the AI.
</p>
<ImageUpload
images={images}
onImagesChange={setImages}
disabled={isSaving}
/>
</div>
)}
{/* Review Requirement Toggle */}
<div className="flex items-start gap-3 p-4 rounded-lg border border-border bg-muted/30">
<Checkbox
id="edit-require-review"
checked={requireReviewBeforeCoding}
onCheckedChange={(checked) => setRequireReviewBeforeCoding(checked === true)}
disabled={isSaving}
className="mt-0.5"
/>
<div className="flex-1 space-y-1">
<Label
htmlFor="edit-require-review"
className="text-sm font-medium text-foreground cursor-pointer"
>
Require human review before coding
</Label>
<p className="text-xs text-muted-foreground">
When enabled, you&apos;ll be prompted to review the spec and implementation plan before the coding phase begins. This allows you to approve, request changes, or provide feedback.
</p>
</div>
</div>
{/* Error */}
{error && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive" role="alert">
<X className="h-4 w-4 mt-0.5 shrink-0" />
<span>{error}</span>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isSaving}>
Cancel
<TaskModalLayout
open={open}
onOpenChange={onOpenChange}
title={t('tasks:edit.title')}
description={t('tasks:edit.description')}
disabled={isSaving}
footer={
<div className="flex items-center justify-end gap-3">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
{t('common:buttons.cancel')}
</Button>
<Button
onClick={handleSave}
disabled={isSaving || !isValid}
>
<Button onClick={handleSave} disabled={isSaving || !isValid}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
{t('common:buttons.saving')}
</>
) : (
'Save Changes'
t('tasks:edit.saveChanges')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
}
>
<TaskFormFields
description={description}
onDescriptionChange={setDescription}
title={title}
onTitleChange={setTitle}
profileId={profileId}
model={model}
thinkingLevel={thinkingLevel}
phaseModels={phaseModels}
phaseThinking={phaseThinking}
onProfileChange={(newProfileId, newModel, newThinkingLevel) => {
setProfileId(newProfileId);
setModel(newModel);
setThinkingLevel(newThinkingLevel);
}}
onModelChange={setModel}
onThinkingLevelChange={setThinkingLevel}
onPhaseModelsChange={setPhaseModels}
onPhaseThinkingChange={setPhaseThinking}
category={category}
priority={priority}
complexity={complexity}
impact={impact}
onCategoryChange={setCategory}
onPriorityChange={setPriority}
onComplexityChange={setComplexity}
onImpactChange={setImpact}
showClassification={showClassification}
onShowClassificationChange={setShowClassification}
images={images}
onImagesChange={setImages}
requireReviewBeforeCoding={requireReviewBeforeCoding}
onRequireReviewChange={setRequireReviewBeforeCoding}
disabled={isSaving}
error={error}
onError={setError}
idPrefix="edit"
/>
</TaskModalLayout>
);
}
@@ -0,0 +1,163 @@
/**
* ClassificationFields - Shared component for task classification fields
*
* Renders the 2x2 grid of classification dropdowns (category, priority, complexity, impact)
* used in both TaskCreationWizard and TaskEditDialog.
*/
import { useTranslation } from 'react-i18next';
import { Label } from '../ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '../ui/select';
import type { TaskCategory, TaskPriority, TaskComplexity, TaskImpact } from '../../../shared/types';
// Classification option keys (values are used for translation key lookup)
const CATEGORY_OPTIONS: TaskCategory[] = ['feature', 'bug_fix', 'refactoring', 'documentation', 'security'];
const PRIORITY_OPTIONS: TaskPriority[] = ['low', 'medium', 'high', 'urgent'];
const COMPLEXITY_OPTIONS: TaskComplexity[] = ['trivial', 'small', 'medium', 'large', 'complex'];
const IMPACT_OPTIONS: TaskImpact[] = ['low', 'medium', 'high', 'critical'];
interface ClassificationFieldsProps {
/** Current category value */
category: TaskCategory | '';
/** Current priority value */
priority: TaskPriority | '';
/** Current complexity value */
complexity: TaskComplexity | '';
/** Current impact value */
impact: TaskImpact | '';
/** Callback when category changes */
onCategoryChange: (value: TaskCategory | '') => void;
/** Callback when priority changes */
onPriorityChange: (value: TaskPriority | '') => void;
/** Callback when complexity changes */
onComplexityChange: (value: TaskComplexity | '') => void;
/** Callback when impact changes */
onImpactChange: (value: TaskImpact | '') => void;
/** Whether the fields are disabled */
disabled?: boolean;
/** Optional ID prefix for form elements (for accessibility) */
idPrefix?: string;
}
export function ClassificationFields({
category,
priority,
complexity,
impact,
onCategoryChange,
onPriorityChange,
onComplexityChange,
onImpactChange,
disabled = false,
idPrefix = ''
}: ClassificationFieldsProps) {
const { t } = useTranslation('tasks');
const prefix = idPrefix ? `${idPrefix}-` : '';
return (
<div className="space-y-4 p-4 rounded-lg border border-border bg-muted/30">
<div className="grid grid-cols-2 gap-4">
{/* Category */}
<div className="space-y-2">
<Label htmlFor={`${prefix}category`} className="text-xs font-medium text-muted-foreground">
{t('form.classification.category')}
</Label>
<Select
value={category}
onValueChange={(value) => onCategoryChange(value as TaskCategory)}
disabled={disabled}
>
<SelectTrigger id={`${prefix}category`} className="h-9">
<SelectValue placeholder={t('form.classification.selectCategory')} />
</SelectTrigger>
<SelectContent>
{CATEGORY_OPTIONS.map((value) => (
<SelectItem key={value} value={value}>
{t(`form.classification.values.category.${value}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Priority */}
<div className="space-y-2">
<Label htmlFor={`${prefix}priority`} className="text-xs font-medium text-muted-foreground">
{t('form.classification.priority')}
</Label>
<Select
value={priority}
onValueChange={(value) => onPriorityChange(value as TaskPriority)}
disabled={disabled}
>
<SelectTrigger id={`${prefix}priority`} className="h-9">
<SelectValue placeholder={t('form.classification.selectPriority')} />
</SelectTrigger>
<SelectContent>
{PRIORITY_OPTIONS.map((value) => (
<SelectItem key={value} value={value}>
{t(`form.classification.values.priority.${value}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Complexity */}
<div className="space-y-2">
<Label htmlFor={`${prefix}complexity`} className="text-xs font-medium text-muted-foreground">
{t('form.classification.complexity')}
</Label>
<Select
value={complexity}
onValueChange={(value) => onComplexityChange(value as TaskComplexity)}
disabled={disabled}
>
<SelectTrigger id={`${prefix}complexity`} className="h-9">
<SelectValue placeholder={t('form.classification.selectComplexity')} />
</SelectTrigger>
<SelectContent>
{COMPLEXITY_OPTIONS.map((value) => (
<SelectItem key={value} value={value}>
{t(`form.classification.values.complexity.${value}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Impact */}
<div className="space-y-2">
<Label htmlFor={`${prefix}impact`} className="text-xs font-medium text-muted-foreground">
{t('form.classification.impact')}
</Label>
<Select
value={impact}
onValueChange={(value) => onImpactChange(value as TaskImpact)}
disabled={disabled}
>
<SelectTrigger id={`${prefix}impact`} className="h-9">
<SelectValue placeholder={t('form.classification.selectImpact')} />
</SelectTrigger>
<SelectContent>
{IMPACT_OPTIONS.map((value) => (
<SelectItem key={value} value={value}>
{t(`form.classification.values.impact.${value}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">
{t('form.classification.helpText')}
</p>
</div>
);
}
@@ -0,0 +1,347 @@
/**
* TaskFormFields - Shared form fields component for task create/edit
*
* Bundles the common form fields used in both TaskCreationWizard and TaskEditDialog:
* - Description (required, with image paste/drop support)
* - Title (optional)
* - Agent profile selector
* - Classification fields (collapsible)
* - Image thumbnails
* - Review requirement checkbox
*/
import { useRef, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronDown, ChevronUp, Image as ImageIcon, X } from 'lucide-react';
import { Label } from '../ui/label';
import { Input } from '../ui/input';
import { Textarea } from '../ui/textarea';
import { Checkbox } from '../ui/checkbox';
import { AgentProfileSelector } from '../AgentProfileSelector';
import { ClassificationFields } from './ClassificationFields';
import { useImageUpload } from './useImageUpload';
import { cn } from '../../lib/utils';
import type {
TaskCategory,
TaskPriority,
TaskComplexity,
TaskImpact,
ImageAttachment,
ModelType,
ThinkingLevel
} from '../../../shared/types';
import type { PhaseModelConfig, PhaseThinkingConfig } from '../../../shared/types/settings';
interface TaskFormFieldsProps {
// Description field
description: string;
onDescriptionChange: (value: string) => void;
descriptionPlaceholder?: string;
/** Optional custom content to render inside the description field (e.g., autocomplete popup) */
descriptionOverlay?: ReactNode;
/** Optional ref for the description textarea (used for @ mention autocomplete positioning) */
descriptionRef?: React.RefObject<HTMLTextAreaElement | null>;
// Title field
title: string;
onTitleChange: (value: string) => void;
// Agent profile
profileId: string;
model: ModelType | '';
thinkingLevel: ThinkingLevel | '';
phaseModels?: PhaseModelConfig;
phaseThinking?: PhaseThinkingConfig;
onProfileChange: (profileId: string, model: ModelType | '', thinkingLevel: ThinkingLevel | '') => void;
onModelChange: (model: ModelType | '') => void;
onThinkingLevelChange: (level: ThinkingLevel | '') => void;
onPhaseModelsChange: (config: PhaseModelConfig | undefined) => void;
onPhaseThinkingChange: (config: PhaseThinkingConfig | undefined) => void;
// Classification
category: TaskCategory | '';
priority: TaskPriority | '';
complexity: TaskComplexity | '';
impact: TaskImpact | '';
onCategoryChange: (value: TaskCategory | '') => void;
onPriorityChange: (value: TaskPriority | '') => void;
onComplexityChange: (value: TaskComplexity | '') => void;
onImpactChange: (value: TaskImpact | '') => void;
showClassification: boolean;
onShowClassificationChange: (show: boolean) => void;
// Images
images: ImageAttachment[];
onImagesChange: (images: ImageAttachment[]) => void;
// Review requirement
requireReviewBeforeCoding: boolean;
onRequireReviewChange: (require: boolean) => void;
// Form state
disabled?: boolean;
error?: string | null;
onError?: (error: string | null) => void;
// ID prefix for accessibility
idPrefix?: string;
/** Optional children to render after description (e.g., @ mention highlight overlay) */
children?: ReactNode;
}
export function TaskFormFields({
description,
onDescriptionChange,
descriptionPlaceholder,
descriptionOverlay,
descriptionRef: externalDescriptionRef,
title,
onTitleChange,
profileId,
model,
thinkingLevel,
phaseModels,
phaseThinking,
onProfileChange,
onModelChange,
onThinkingLevelChange,
onPhaseModelsChange,
onPhaseThinkingChange,
category,
priority,
complexity,
impact,
onCategoryChange,
onPriorityChange,
onComplexityChange,
onImpactChange,
showClassification,
onShowClassificationChange,
images,
onImagesChange,
requireReviewBeforeCoding,
onRequireReviewChange,
disabled = false,
error,
onError,
idPrefix = '',
children
}: TaskFormFieldsProps) {
const { t } = useTranslation(['tasks', 'common']);
// Use external ref if provided (for @ mention autocomplete), otherwise use internal ref
const internalDescriptionRef = useRef<HTMLTextAreaElement>(null);
const descriptionRef = externalDescriptionRef || internalDescriptionRef;
const prefix = idPrefix ? `${idPrefix}-` : '';
// Use the shared image upload hook with translated error messages
const {
isDragOver,
pasteSuccess,
handlePaste,
handleDragOver,
handleDragLeave,
handleDrop,
removeImage
} = useImageUpload({
images,
onImagesChange,
disabled,
onError,
errorMessages: {
maxImagesReached: t('tasks:form.errors.maxImagesReached'),
invalidImageType: t('tasks:form.errors.invalidImageType'),
processPasteFailed: t('tasks:form.errors.processPasteFailed'),
processDropFailed: t('tasks:form.errors.processDropFailed')
}
});
return (
<div className="space-y-6">
{/* Description (Primary - Required) */}
<div className="space-y-2">
<Label htmlFor={`${prefix}description`} className="text-sm font-medium text-foreground">
{t('tasks:form.description')} <span className="text-destructive">*</span>
</Label>
<div className="relative">
{/* Optional overlay (e.g., @ mention highlighting) */}
{descriptionOverlay}
<Textarea
ref={descriptionRef}
id={`${prefix}description`}
placeholder={descriptionPlaceholder || t('tasks:form.descriptionPlaceholder')}
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
onPaste={handlePaste}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
rows={6}
disabled={disabled}
aria-required="true"
aria-describedby={`${prefix}description-help`}
className={cn(
'resize-y min-h-[150px] max-h-[400px] relative',
descriptionOverlay && 'bg-transparent',
isDragOver && !disabled && 'border-primary bg-primary/5 ring-2 ring-primary/20'
)}
style={descriptionOverlay ? { caretColor: 'auto' } : undefined}
/>
</div>
<p id={`${prefix}description-help`} className="text-xs text-muted-foreground">
{t('images.pasteHint', { shortcut: navigator.platform.includes('Mac') ? '⌘V' : 'Ctrl+V' })}
</p>
{/* Image Thumbnails - displayed inline below description */}
{images.length > 0 && (
<div className="flex flex-wrap gap-2 mt-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: '72px', height: '72px' }}
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 */}
{!disabled && (
<button
type="button"
className="absolute top-0.5 right-0.5 h-5 w-5 flex items-center justify-center rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
removeImage(image.id);
}}
aria-label={t('images.removeImageAriaLabel', { filename: image.filename })}
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
)}
{/* Optional children (e.g., @ mention autocomplete) */}
{children}
</div>
{/* Paste Success Indicator */}
{pasteSuccess && (
<div className="flex items-center gap-2 text-sm text-success animate-in fade-in slide-in-from-top-1 duration-200">
<ImageIcon className="h-4 w-4" />
{t('tasks:form.imageAddedSuccess')}
</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"
onClick={() => onShowClassificationChange(!showClassification)}
className={cn(
'flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors',
'w-full justify-between py-2 px-3 rounded-md hover:bg-muted/50'
)}
disabled={disabled}
aria-expanded={showClassification}
aria-controls={`${prefix}classification-section`}
>
<span>{t('tasks:form.classificationOptional')}</span>
{showClassification ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{/* Classification Fields */}
{showClassification && (
<div id={`${prefix}classification-section`}>
<ClassificationFields
category={category}
priority={priority}
complexity={complexity}
impact={impact}
onCategoryChange={onCategoryChange}
onPriorityChange={onPriorityChange}
onComplexityChange={onComplexityChange}
onImpactChange={onImpactChange}
disabled={disabled}
idPrefix={idPrefix}
/>
</div>
)}
{/* Review Requirement Toggle */}
<div className="flex items-start gap-3 p-4 rounded-lg border border-border bg-muted/30">
<Checkbox
id={`${prefix}require-review`}
checked={requireReviewBeforeCoding}
onCheckedChange={(checked) => onRequireReviewChange(checked === true)}
disabled={disabled}
className="mt-0.5"
/>
<div className="flex-1 space-y-1">
<Label
htmlFor={`${prefix}require-review`}
className="text-sm font-medium text-foreground cursor-pointer"
>
{t('tasks:form.requireReviewLabel')}
</Label>
<p className="text-xs text-muted-foreground">
{t('tasks:form.requireReviewDescription')}
</p>
</div>
</div>
{/* Error Display */}
{error && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive" role="alert">
<X className="h-4 w-4 mt-0.5 shrink-0" />
<span>{error}</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,139 @@
/**
* TaskModalLayout - Shared layout component for large task modals
*
* Provides consistent styling matching TaskDetailModal exactly:
* - Full-height modal (95vw width, near full height)
* - Positioned 16px from top (same as TaskDetailModal)
* - Header with title, description, and close button
* - Scrollable body content
* - Footer with action buttons
*/
import { useTranslation } from 'react-i18next';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
import { cn } from '../../lib/utils';
import type { ReactNode } from 'react';
interface TaskModalLayoutProps {
/** Whether the modal is open */
open: boolean;
/** Callback when open state changes */
onOpenChange: (open: boolean) => void;
/** Modal title */
title: string;
/** Modal description */
description?: string;
/** Main content of the modal */
children: ReactNode;
/** Footer content (action buttons) */
footer: ReactNode;
/** Optional sidebar content (e.g., file explorer) */
sidebar?: ReactNode;
/** Whether sidebar is visible */
sidebarOpen?: boolean;
/** Whether the modal is in a loading/disabled state */
disabled?: boolean;
}
export function TaskModalLayout({
open,
onOpenChange,
title,
description,
children,
footer,
sidebar,
sidebarOpen = false,
disabled = false
}: TaskModalLayoutProps) {
const { t } = useTranslation('common');
const handleClose = () => {
if (!disabled) {
onOpenChange(false);
}
};
return (
<DialogPrimitive.Root open={open} onOpenChange={handleClose}>
<DialogPrimitive.Portal>
{/* Semi-transparent overlay */}
<DialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/60',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0'
)}
/>
{/* Full-height modal content - matches TaskDetailModal exactly */}
<DialogPrimitive.Content
className={cn(
'fixed left-[50%] top-4 z-50',
'translate-x-[-50%]',
'w-[95vw] max-w-5xl h-[calc(100vh-32px)]',
'bg-card border border-border rounded-xl',
'shadow-2xl overflow-hidden flex flex-col',
'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'
)}
>
<div className="flex h-full min-h-0 overflow-hidden">
{/* Main content area */}
<div className="flex-1 flex flex-col min-w-0 min-h-0 overflow-hidden">
{/* Header */}
<div className="px-6 py-5 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<DialogPrimitive.Title className="text-xl font-semibold leading-tight text-foreground">
{title}
</DialogPrimitive.Title>
{description && (
<DialogPrimitive.Description className="mt-1.5 text-sm text-muted-foreground">
{description}
</DialogPrimitive.Description>
)}
</div>
<DialogPrimitive.Close asChild>
<Button
variant="ghost"
size="icon"
className="hover:bg-muted transition-colors shrink-0"
disabled={disabled}
>
<X className="h-5 w-5" />
<span className="sr-only">{t('buttons.close')}</span>
</Button>
</DialogPrimitive.Close>
</div>
</div>
{/* Scrollable body */}
<ScrollArea className="flex-1 min-h-0">
<div className="p-6">
{children}
</div>
</ScrollArea>
{/* Footer */}
<div className="px-6 py-4 border-t border-border shrink-0 bg-muted/30">
{footer}
</div>
</div>
{/* Optional sidebar */}
{sidebar && sidebarOpen && (
<div className="w-80 border-l border-border flex-shrink-0 overflow-hidden">
{sidebar}
</div>
)}
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
@@ -0,0 +1,14 @@
/**
* Task Form Components - Shared components for task creation and editing
*
* This module provides reusable components for the task form UI:
* - TaskModalLayout: Consistent large modal wrapper
* - TaskFormFields: Common form fields (description, title, agent profile, etc.)
* - ClassificationFields: Task classification dropdowns
* - useImageUpload: Hook for image paste/drop handling
*/
export { TaskModalLayout } from './TaskModalLayout';
export { TaskFormFields } from './TaskFormFields';
export { ClassificationFields } from './ClassificationFields';
export { useImageUpload } from './useImageUpload';
@@ -0,0 +1,288 @@
/**
* useImageUpload - Shared hook for handling image paste and drag-drop in task forms
*
* Extracts the duplicated image handling logic from TaskCreationWizard and TaskEditDialog
* into a reusable hook.
*/
import { useState, useCallback, useRef, useEffect, useMemo, type ClipboardEvent, type DragEvent } from 'react';
import {
generateImageId,
blobToBase64,
createThumbnail,
isValidImageMimeType,
resolveFilename
} from '../ImageUpload';
import type { ImageAttachment } from '../../../shared/types';
import {
MAX_IMAGES_PER_TASK,
ALLOWED_IMAGE_TYPES_DISPLAY
} from '../../../shared/constants';
/** Error messages that can be customized/translated by callers */
interface ImageUploadErrorMessages {
maxImagesReached?: string;
invalidImageType?: string;
processPasteFailed?: string;
processDropFailed?: string;
}
interface UseImageUploadOptions {
/** Current images array */
images: ImageAttachment[];
/** Callback when images change */
onImagesChange: (images: ImageAttachment[]) => void;
/** Whether the form is disabled (e.g., during submission) */
disabled?: boolean;
/** Callback to set error message */
onError?: (error: string | null) => void;
/** Custom error messages for i18n support */
errorMessages?: ImageUploadErrorMessages;
}
interface UseImageUploadReturn {
/** Whether user is dragging over the textarea */
isDragOver: boolean;
/** Whether an image was just successfully added */
pasteSuccess: boolean;
/** Handle paste event on textarea */
handlePaste: (e: ClipboardEvent<HTMLTextAreaElement>) => Promise<void>;
/** Handle drag over event on textarea */
handleDragOver: (e: DragEvent<HTMLTextAreaElement>) => void;
/** Handle drag leave event on textarea */
handleDragLeave: (e: DragEvent<HTMLTextAreaElement>) => void;
/** Handle drop event on textarea */
handleDrop: (e: DragEvent<HTMLTextAreaElement>) => Promise<void>;
/** Remove an image by ID */
removeImage: (imageId: string) => void;
/** Whether more images can be added */
canAddMore: boolean;
/** Number of remaining image slots */
remainingSlots: number;
}
// Default error messages (English fallbacks)
const DEFAULT_ERROR_MESSAGES: Required<ImageUploadErrorMessages> = {
maxImagesReached: `Maximum of ${MAX_IMAGES_PER_TASK} images allowed`,
invalidImageType: `Invalid image type. Allowed: ${ALLOWED_IMAGE_TYPES_DISPLAY}`,
processPasteFailed: 'Failed to process pasted image',
processDropFailed: 'Failed to process dropped image'
};
export function useImageUpload({
images,
onImagesChange,
disabled = false,
onError,
errorMessages = {}
}: UseImageUploadOptions): UseImageUploadReturn {
const [isDragOver, setIsDragOver] = useState(false);
const [pasteSuccess, setPasteSuccess] = useState(false);
const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Merge custom error messages with defaults (memoized to prevent useCallback invalidation)
const errors = useMemo<Required<ImageUploadErrorMessages>>(() => ({
...DEFAULT_ERROR_MESSAGES,
...errorMessages
}), [errorMessages]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (successTimeoutRef.current) {
clearTimeout(successTimeoutRef.current);
}
};
}, []);
const remainingSlots = MAX_IMAGES_PER_TASK - images.length;
const canAddMore = remainingSlots > 0;
/**
* Process image items and add them to the images array
*/
const processImageItems = useCallback(
async (
items: DataTransferItem[] | File[],
options: { isFromPaste?: boolean } = {}
) => {
if (disabled) return;
if (remainingSlots <= 0) {
onError?.(errors.maxImagesReached);
return;
}
onError?.(null);
const newImages: ImageAttachment[] = [];
const existingFilenames = images.map((img) => img.filename);
// Process items up to remaining slots
const itemsToProcess = items.slice(0, remainingSlots);
for (const item of itemsToProcess) {
let file: File | null = null;
if (item instanceof File) {
file = item;
} else if ('getAsFile' in item) {
file = item.getAsFile();
}
if (!file) continue;
// Validate image type
if (!isValidImageMimeType(file.type)) {
onError?.(errors.invalidImageType);
continue;
}
try {
const dataUrl = await blobToBase64(file);
const thumbnail = await createThumbnail(dataUrl);
// Generate filename based on source
let baseFilename: string;
if (options.isFromPaste || !file.name || file.name === 'image.png') {
const extension = file.type.split('/')[1] || 'png';
baseFilename = `screenshot-${Date.now()}.${extension}`;
} else {
baseFilename = file.name;
}
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('Image processing error:', error);
onError?.(options.isFromPaste ? errors.processPasteFailed : errors.processDropFailed);
}
}
if (newImages.length > 0) {
onImagesChange([...images, ...newImages]);
// Show success feedback (clear any existing timeout first)
if (successTimeoutRef.current) {
clearTimeout(successTimeoutRef.current);
}
setPasteSuccess(true);
successTimeoutRef.current = setTimeout(() => setPasteSuccess(false), 2000);
}
},
[images, onImagesChange, disabled, remainingSlots, onError, errors]
);
/**
* Handle paste event for screenshot support
*/
const handlePaste = useCallback(
async (e: ClipboardEvent<HTMLTextAreaElement>) => {
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();
await processImageItems(imageItems, { isFromPaste: true });
},
[processImageItems]
);
/**
* Handle drag over textarea
*/
const handleDragOver = useCallback((e: DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}, []);
/**
* Handle drag leave from textarea
*/
const handleDragLeave = useCallback((e: DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
/**
* Handle drop on textarea for image files
* Note: Only prevents default if image files are detected, allowing file reference
* drops (which use text/plain) to work via browser's default behavior
*/
const handleDrop = useCallback(
async (e: DragEvent<HTMLTextAreaElement>) => {
setIsDragOver(false);
if (disabled) return;
const files = e.dataTransfer?.files;
// Filter for image files
const imageFiles: File[] = [];
if (files && files.length > 0) {
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.type.startsWith('image/')) {
imageFiles.push(file);
}
}
}
// Only prevent default if we have image files to process
// This allows file reference drops (@mention text) to work via default behavior
if (imageFiles.length === 0) return;
e.preventDefault();
e.stopPropagation();
await processImageItems(imageFiles, { isFromPaste: false });
},
[disabled, processImageItems]
);
/**
* Remove an image by ID
*/
const removeImage = useCallback(
(imageId: string) => {
onImagesChange(images.filter((img) => img.id !== imageId));
},
[images, onImagesChange]
);
return {
isDragOver,
pasteSuccess,
handlePaste,
handleDragOver,
handleDragLeave,
handleDrop,
removeImage,
canAddMore,
remainingSlots
};
}
@@ -131,9 +131,91 @@
"backgroundTaskDescription": "The task is still running. You can reopen this dialog to monitor progress."
},
"wizard": {
"createTitle": "Create New Task",
"createDescription": "Describe what you want to build. The AI will analyze your request and create a detailed specification.",
"descriptionPlaceholder": "Describe the feature, bug fix, or improvement you want to implement. Be as specific as possible about requirements, constraints, and expected behavior. Type @ to reference files.",
"draftRestored": "Draft restored",
"startFresh": "Start Fresh",
"hideFiles": "Hide Files",
"browseFiles": "Browse Files",
"creating": "Creating...",
"createTask": "Create Task",
"gitOptions": {
"title": "Git Options (optional)",
"baseBranchLabel": "Base Branch (optional)",
"useProjectDefault": "Use project default",
"useProjectDefaultWithBranch": "Use project default ({{branch}})",
"helpText": "Override the branch this task's worktree will be created from. Leave empty to use the project's configured default branch.",
"useWorktreeLabel": "Use isolated workspace (recommended)",
"useWorktreeDescription": "Creates changes in a separate git worktree for safe review before merging. Disable to build directly in your project (faster but riskier)."
},
"errors": {
"createFailed": "Failed to create task. Please try again."
}
},
"edit": {
"title": "Edit Task",
"description": "Update task details including title, description, classification, images, and settings. Changes will be saved to the spec files.",
"saveChanges": "Save Changes",
"errors": {
"updateFailed": "Failed to update task. Please try again."
}
},
"form": {
"description": "Description",
"descriptionPlaceholder": "Describe the feature, bug fix, or improvement you want to implement. Be as specific as possible about requirements, constraints, and expected behavior.",
"imageAddedSuccess": "Image added successfully!",
"taskTitle": "Task Title",
"titlePlaceholder": "Leave empty to auto-generate from description",
"titleHelpText": "A short, descriptive title will be generated automatically if left empty.",
"classificationOptional": "Classification (optional)",
"requireReviewLabel": "Require human review before coding",
"requireReviewDescription": "When enabled, you'll be prompted to review the spec and implementation plan before the coding phase begins. This allows you to approve, request changes, or provide feedback.",
"errors": {
"descriptionRequired": "Please provide a description",
"maxImagesReached": "Maximum of 5 images allowed",
"invalidImageType": "Invalid image type. Allowed: PNG, JPEG, GIF, WebP",
"processPasteFailed": "Failed to process pasted image",
"processDropFailed": "Failed to process dropped image"
},
"classification": {
"category": "Category",
"selectCategory": "Select category",
"priority": "Priority",
"selectPriority": "Select priority",
"complexity": "Complexity",
"selectComplexity": "Select complexity",
"impact": "Impact",
"selectImpact": "Select impact",
"helpText": "These labels help organize and prioritize tasks. They're optional but useful for filtering.",
"values": {
"category": {
"feature": "Feature",
"bug_fix": "Bug Fix",
"refactoring": "Refactoring",
"documentation": "Docs",
"security": "Security"
},
"priority": {
"low": "Low",
"medium": "Medium",
"high": "High",
"urgent": "Urgent"
},
"complexity": {
"trivial": "Trivial",
"small": "Small",
"medium": "Medium",
"large": "Large",
"complex": "Complex"
},
"impact": {
"low": "Low Impact",
"medium": "Medium Impact",
"high": "High Impact",
"critical": "Critical Impact"
}
}
}
},
"subtasks": {
@@ -131,9 +131,91 @@
"backgroundTaskDescription": "La tâche est toujours en cours. Vous pouvez rouvrir cette boîte de dialogue pour suivre la progression."
},
"wizard": {
"createTitle": "Créer une nouvelle tâche",
"createDescription": "Décrivez ce que vous voulez construire. L'IA analysera votre demande et créera une spécification détaillée.",
"descriptionPlaceholder": "Décrivez la fonctionnalité, la correction de bug ou l'amélioration que vous souhaitez implémenter. Soyez aussi précis que possible sur les exigences, les contraintes et le comportement attendu. Tapez @ pour référencer des fichiers.",
"draftRestored": "Brouillon restauré",
"startFresh": "Recommencer",
"hideFiles": "Masquer les fichiers",
"browseFiles": "Parcourir les fichiers",
"creating": "Création...",
"createTask": "Créer la tâche",
"gitOptions": {
"title": "Options Git (optionnel)",
"baseBranchLabel": "Branche de base (optionnel)",
"useProjectDefault": "Utiliser la branche par défaut du projet",
"useProjectDefaultWithBranch": "Utiliser la branche par défaut du projet ({{branch}})",
"helpText": "Remplacez la branche à partir de laquelle le worktree de cette tâche sera créé. Laissez vide pour utiliser la branche par défaut configurée du projet.",
"useWorktreeLabel": "Utiliser un espace de travail isolé (recommandé)",
"useWorktreeDescription": "Crée les changements dans un worktree git séparé pour une révision sécurisée avant la fusion. Désactivez pour travailler directement dans votre projet (plus rapide mais risqué)."
},
"errors": {
"createFailed": "Échec de la création de la tâche. Veuillez réessayer."
}
},
"edit": {
"title": "Modifier la tâche",
"description": "Mettez à jour les détails de la tâche, y compris le titre, la description, la classification, les images et les paramètres. Les modifications seront enregistrées dans les fichiers de spécification.",
"saveChanges": "Enregistrer les modifications",
"errors": {
"updateFailed": "Échec de la mise à jour de la tâche. Veuillez réessayer."
}
},
"form": {
"description": "Description",
"descriptionPlaceholder": "Décrivez la fonctionnalité, la correction de bug ou l'amélioration que vous souhaitez implémenter. Soyez aussi précis que possible sur les exigences, les contraintes et le comportement attendu.",
"imageAddedSuccess": "Image ajoutée avec succès !",
"taskTitle": "Titre de la tâche",
"titlePlaceholder": "Laissez vide pour générer automatiquement à partir de la description",
"titleHelpText": "Un titre court et descriptif sera généré automatiquement s'il est laissé vide.",
"classificationOptional": "Classification (optionnel)",
"requireReviewLabel": "Exiger une révision humaine avant le codage",
"requireReviewDescription": "Lorsque activé, vous serez invité à réviser la spécification et le plan d'implémentation avant le début de la phase de codage. Cela vous permet d'approuver, de demander des modifications ou de fournir des commentaires.",
"errors": {
"descriptionRequired": "Veuillez fournir une description",
"maxImagesReached": "Maximum de 5 images autorisé",
"invalidImageType": "Type d'image non valide. Autorisés : PNG, JPEG, GIF, WebP",
"processPasteFailed": "Échec du traitement de l'image collée",
"processDropFailed": "Échec du traitement de l'image déposée"
},
"classification": {
"category": "Catégorie",
"selectCategory": "Sélectionner une catégorie",
"priority": "Priorité",
"selectPriority": "Sélectionner une priorité",
"complexity": "Complexité",
"selectComplexity": "Sélectionner une complexité",
"impact": "Impact",
"selectImpact": "Sélectionner un impact",
"helpText": "Ces étiquettes aident à organiser et à prioriser les tâches. Elles sont optionnelles mais utiles pour le filtrage.",
"values": {
"category": {
"feature": "Fonctionnalité",
"bug_fix": "Correction de bug",
"refactoring": "Refactoring",
"documentation": "Documentation",
"security": "Sécurité"
},
"priority": {
"low": "Basse",
"medium": "Moyenne",
"high": "Haute",
"urgent": "Urgente"
},
"complexity": {
"trivial": "Triviale",
"small": "Petite",
"medium": "Moyenne",
"large": "Grande",
"complex": "Complexe"
},
"impact": {
"low": "Impact faible",
"medium": "Impact moyen",
"high": "Impact élevé",
"critical": "Impact critique"
}
}
}
},
"subtasks": {