From df540ec512e9a3814c98932344265b077f488d5b Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Sat, 10 Jan 2026 21:05:42 +0100 Subject: [PATCH] =?UTF-8?q?refactor(ui):=20extract=20shared=20task=20form?= =?UTF-8?q?=20components=20for=20consistent=20moda=E2=80=A6=20(#765)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 4.5 --- .../components/TaskCreationWizard.tsx | 1142 +++++------------ .../renderer/components/TaskEditDialog.tsx | 627 ++------- .../task-form/ClassificationFields.tsx | 163 +++ .../components/task-form/TaskFormFields.tsx | 347 +++++ .../components/task-form/TaskModalLayout.tsx | 139 ++ .../renderer/components/task-form/index.ts | 14 + .../components/task-form/useImageUpload.ts | 288 +++++ .../src/shared/i18n/locales/en/tasks.json | 82 ++ .../src/shared/i18n/locales/fr/tasks.json | 82 ++ 9 files changed, 1486 insertions(+), 1398 deletions(-) create mode 100644 apps/frontend/src/renderer/components/task-form/ClassificationFields.tsx create mode 100644 apps/frontend/src/renderer/components/task-form/TaskFormFields.tsx create mode 100644 apps/frontend/src/renderer/components/task-form/TaskModalLayout.tsx create mode 100644 apps/frontend/src/renderer/components/task-form/index.ts create mode 100644 apps/frontend/src/renderer/components/task-form/useImageUpload.ts diff --git a/apps/frontend/src/renderer/components/TaskCreationWizard.tsx b/apps/frontend/src/renderer/components/TaskCreationWizard.tsx index 7f67f3e2..aa0fd3c4 100644 --- a/apps/frontend/src/renderer/components/TaskCreationWizard.tsx +++ b/apps/frontend/src/renderer/components/TaskCreationWizard.tsx @@ -1,19 +1,20 @@ -import { useState, useEffect, useCallback, useRef, useMemo, type ClipboardEvent, type DragEvent } from 'react'; +/** + * TaskCreationWizard - Dialog for creating new tasks + * + * Now uses the shared TaskModalLayout for consistent styling with other task modals, + * and TaskFormFields for the form content. + * + * Features unique to creation (not in TaskEditDialog): + * - Draft persistence (auto-save to localStorage) + * - @ mention autocomplete for file references + * - File explorer drawer sidebar + * - Git branch selection options + */ +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Loader2, ChevronDown, ChevronUp, Image as ImageIcon, X, RotateCcw, FolderTree, GitBranch } from 'lucide-react'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from './ui/dialog'; +import { Loader2, ChevronDown, ChevronUp, RotateCcw, FolderTree, GitBranch } 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, @@ -21,15 +22,9 @@ import { SelectTrigger, SelectValue } from './ui/select'; -import { - generateImageId, - blobToBase64, - createThumbnail, - isValidImageMimeType, - resolveFilename -} from './ImageUpload'; +import { TaskModalLayout } from './task-form/TaskModalLayout'; +import { TaskFormFields } from './task-form/TaskFormFields'; import { TaskFileExplorerDrawer } from './TaskFileExplorerDrawer'; -import { AgentProfileSelector } from './AgentProfileSelector'; import { FileAutocomplete } from './FileAutocomplete'; import { createTask, saveDraft, loadDraft, clearDraft, isDraftEmpty } from '../stores/task-store'; import { useProjectStore } from '../stores/project-store'; @@ -37,12 +32,6 @@ import { cn } from '../lib/utils'; import type { TaskCategory, TaskPriority, TaskComplexity, TaskImpact, TaskMetadata, ImageAttachment, TaskDraft, ModelType, ThinkingLevel, ReferencedFile } from '../../shared/types'; import type { PhaseModelConfig, PhaseThinkingConfig } from '../../shared/types/settings'; 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 @@ -55,29 +44,30 @@ interface TaskCreationWizardProps { onOpenChange: (open: boolean) => void; } +// Special value for "use project default" branch +const PROJECT_DEFAULT_BRANCH = '__project_default__'; + export function TaskCreationWizard({ projectId, open, onOpenChange }: TaskCreationWizardProps) { - const { t } = useTranslation('tasks'); - // Get selected agent profile from settings + const { t } = useTranslation(['tasks', 'common']); 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(''); const [description, setDescription] = useState(''); const [isCreating, setIsCreating] = useState(false); const [error, setError] = useState(null); - const [showAdvanced, setShowAdvanced] = useState(false); + const [showClassification, setShowClassification] = useState(false); const [showFileExplorer, setShowFileExplorer] = useState(false); const [showGitOptions, setShowGitOptions] = useState(false); // Git options state - // Use a special value to represent "use project default" since Radix UI Select doesn't allow empty string values - const PROJECT_DEFAULT_BRANCH = '__project_default__'; const [branches, setBranches] = useState([]); const [isLoadingBranches, setIsLoadingBranches] = useState(false); const [baseBranch, setBaseBranch] = useState(PROJECT_DEFAULT_BRANCH); @@ -92,18 +82,16 @@ export function TaskCreationWizard({ return project?.path ?? null; }, [projects, projectId]); - // Metadata fields + // Classification fields const [category, setCategory] = useState(''); const [priority, setPriority] = useState(''); const [complexity, setComplexity] = useState(''); const [impact, setImpact] = useState(''); - // Model configuration (initialized from selected agent profile) + // Model configuration const [profileId, setProfileId] = useState(settings.selectedAgentProfile || 'auto'); const [model, setModel] = useState(selectedProfile.model); const [thinkingLevel, setThinkingLevel] = useState(selectedProfile.thinkingLevel); - // Auto profile - per-phase configuration - // Use custom settings from app settings if available, otherwise fall back to defaults const [phaseModels, setPhaseModels] = useState( settings.customPhaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS ); @@ -111,10 +99,8 @@ export function TaskCreationWizard({ settings.customPhaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING ); - // Image attachments + // Images and files const [images, setImages] = useState([]); - - // Referenced files from file explorer const [referencedFiles, setReferencedFiles] = useState([]); // Review setting @@ -122,18 +108,9 @@ export function TaskCreationWizard({ // Draft state const [isDraftRestored, setIsDraftRestored] = useState(false); - const [pasteSuccess, setPasteSuccess] = useState(false); - - // Ref for the textarea to handle paste events - const descriptionRef = useRef(null); - - // Ref for the form scroll container (for drag auto-scroll) - const formContainerRef = useRef(null); - - // Drag-and-drop state for images over textarea - const [isDragOverTextarea, setIsDragOverTextarea] = useState(false); // @ autocomplete state + const descriptionRef = useRef(null); const [autocomplete, setAutocomplete] = useState<{ show: boolean; query: string; @@ -141,7 +118,7 @@ export function TaskCreationWizard({ position: { top: number; left: number }; } | null>(null); - // Load draft when dialog opens, or initialize from selected profile + // Load draft when dialog opens useEffect(() => { if (open && projectId) { const draft = loadDraft(projectId); @@ -152,7 +129,6 @@ export function TaskCreationWizard({ setPriority(draft.priority); setComplexity(draft.complexity); setImpact(draft.impact); - // Load model/thinkingLevel/profileId from draft if present, otherwise use profile defaults setProfileId(draft.profileId || settings.selectedAgentProfile || 'auto'); setModel(draft.model || selectedProfile.model); setThinkingLevel(draft.thinkingLevel || selectedProfile.thinkingLevel); @@ -163,12 +139,11 @@ export function TaskCreationWizard({ setRequireReviewBeforeCoding(draft.requireReviewBeforeCoding ?? false); setIsDraftRestored(true); - // Expand sections if they have content if (draft.category || draft.priority || draft.complexity || draft.impact) { - setShowAdvanced(true); + setShowClassification(true); } } else { - // No draft - initialize from selected profile and custom settings + // No draft - initialize from selected profile setProfileId(settings.selectedAgentProfile || 'auto'); setModel(selectedProfile.model); setThinkingLevel(selectedProfile.thinkingLevel); @@ -176,52 +151,53 @@ export function TaskCreationWizard({ setPhaseThinking(settings.customPhaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING); } } - }, [open, projectId, settings.selectedAgentProfile, settings.customPhaseModels, settings.customPhaseThinking, selectedProfile.model, selectedProfile.thinkingLevel]); + }, [open, projectId, settings.selectedAgentProfile, settings.customPhaseModels, settings.customPhaseThinking, selectedProfile.model, selectedProfile.thinkingLevel, selectedProfile.phaseModels, selectedProfile.phaseThinking]); - // Fetch branches and project default branch when dialog opens + // Fetch branches when dialog opens useEffect(() => { + let isMounted = true; + + const fetchBranches = async () => { + if (!projectPath) return; + if (isMounted) setIsLoadingBranches(true); + try { + const result = await window.electronAPI.getGitBranches(projectPath); + if (isMounted && result.success && result.data) { + setBranches(result.data); + } + } catch (err) { + console.error('Failed to fetch branches:', err); + } finally { + if (isMounted) setIsLoadingBranches(false); + } + }; + + const fetchProjectDefaultBranch = async () => { + if (!projectId) return; + try { + const result = await window.electronAPI.getProjectEnv(projectId); + if (isMounted && result.success && result.data?.defaultBranch) { + setProjectDefaultBranch(result.data.defaultBranch); + } else if (projectPath) { + const detectResult = await window.electronAPI.detectMainBranch(projectPath); + if (isMounted && detectResult.success && detectResult.data) { + setProjectDefaultBranch(detectResult.data); + } + } + } catch (err) { + console.error('Failed to fetch project default branch:', err); + } + }; + if (open && projectPath) { fetchBranches(); fetchProjectDefaultBranch(); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, projectPath]); - const fetchBranches = async () => { - if (!projectPath) return; - - setIsLoadingBranches(true); - try { - const result = await window.electronAPI.getGitBranches(projectPath); - if (result.success && result.data) { - setBranches(result.data); - } - } catch (err) { - console.error('Failed to fetch branches:', err); - } finally { - setIsLoadingBranches(false); - } - }; - - const fetchProjectDefaultBranch = async () => { - if (!projectId) return; - - try { - // Get env config to check if there's a configured default branch - const result = await window.electronAPI.getProjectEnv(projectId); - if (result.success && result.data?.defaultBranch) { - setProjectDefaultBranch(result.data.defaultBranch); - } else if (projectPath) { - // Fall back to auto-detect - const detectResult = await window.electronAPI.detectMainBranch(projectPath); - if (detectResult.success && detectResult.data) { - setProjectDefaultBranch(detectResult.data); - } - } - } catch (err) { - console.error('Failed to fetch project default branch:', err); - } - }; + return () => { + isMounted = false; + }; + }, [open, projectPath, projectId]); /** * Get current form state as a draft @@ -244,97 +220,15 @@ export function TaskCreationWizard({ requireReviewBeforeCoding, savedAt: new Date() }), [projectId, title, description, category, priority, complexity, impact, profileId, model, thinkingLevel, phaseModels, phaseThinking, images, referencedFiles, requireReviewBeforeCoding]); - /** - * Handle paste event for screenshot support - */ - const handlePaste = useCallback(async (e: ClipboardEvent) => { - const clipboardItems = e.clipboardData?.items; - if (!clipboardItems) return; - - // Find image items in clipboard - const imageItems: DataTransferItem[] = []; - for (let i = 0; i < clipboardItems.length; i++) { - const item = clipboardItems[i]; - if (item.type.startsWith('image/')) { - imageItems.push(item); - } - } - - // If no images, allow normal paste behavior - if (imageItems.length === 0) return; - - // Prevent default paste when we have images - e.preventDefault(); - - // Check if we can add more images - const remainingSlots = MAX_IMAGES_PER_TASK - images.length; - if (remainingSlots <= 0) { - setError(`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]); - // Show success feedback - setPasteSuccess(true); - setTimeout(() => setPasteSuccess(false), 2000); - } - }, [images]); /** * Detect @ mention being typed and show autocomplete */ const detectAtMention = useCallback((text: string, cursorPos: number) => { const beforeCursor = text.slice(0, cursorPos); - // Match @ followed by optional path characters (letters, numbers, dots, dashes, slashes) const match = beforeCursor.match(/@([\w\-./\\]*)$/); - if (match) { - return { - query: match[1], - startPos: cursorPos - match[0].length - }; + return { query: match[1], startPos: cursorPos - match[0].length }; } return null; }, []); @@ -342,61 +236,48 @@ export function TaskCreationWizard({ /** * Handle description change and check for @ mentions */ - const handleDescriptionChange = useCallback((e: React.ChangeEvent) => { - const newValue = e.target.value; - const cursorPos = e.target.selectionStart || 0; + const handleDescriptionChange = useCallback((newValue: string) => { + const textarea = descriptionRef.current; + const cursorPos = textarea?.selectionStart || 0; setDescription(newValue); - // Check for @ mention at cursor const mention = detectAtMention(newValue, cursorPos); + if (mention && textarea) { + const rect = textarea.getBoundingClientRect(); + const textareaStyle = window.getComputedStyle(textarea); + const lineHeight = parseFloat(textareaStyle.lineHeight) || 20; + const paddingTop = parseFloat(textareaStyle.paddingTop) || 8; + const paddingLeft = parseFloat(textareaStyle.paddingLeft) || 12; - if (mention) { - // Calculate popup position based on cursor - const textarea = descriptionRef.current; - if (textarea) { - const rect = textarea.getBoundingClientRect(); - const textareaStyle = window.getComputedStyle(textarea); - const lineHeight = parseFloat(textareaStyle.lineHeight) || 20; - const paddingTop = parseFloat(textareaStyle.paddingTop) || 8; - const paddingLeft = parseFloat(textareaStyle.paddingLeft) || 12; + const textBeforeCursor = newValue.slice(0, cursorPos); + const lines = textBeforeCursor.split('\n'); + const currentLineIndex = lines.length - 1; + const currentLineLength = lines[currentLineIndex].length; - // Estimate cursor position (simplified - assumes fixed-width font) - const textBeforeCursor = newValue.slice(0, cursorPos); - const lines = textBeforeCursor.split('\n'); - const currentLineIndex = lines.length - 1; - const currentLineLength = lines[currentLineIndex].length; + const charWidth = 8; + const top = paddingTop + (currentLineIndex + 1) * lineHeight + 4; + const left = paddingLeft + Math.min(currentLineLength * charWidth, rect.width - 300); - // Calculate position relative to textarea - const charWidth = 8; // Approximate character width - const top = paddingTop + (currentLineIndex + 1) * lineHeight + 4; - const left = paddingLeft + Math.min(currentLineLength * charWidth, rect.width - 300); - - setAutocomplete({ - show: true, - query: mention.query, - startPos: mention.startPos, - position: { top, left: Math.max(0, left) } - }); - } - } else { - // No @ mention at cursor, close autocomplete - if (autocomplete?.show) { - setAutocomplete(null); - } + setAutocomplete({ + show: true, + query: mention.query, + startPos: mention.startPos, + position: { top, left: Math.max(0, left) } + }); + } else if (autocomplete?.show) { + setAutocomplete(null); } }, [detectAtMention, autocomplete?.show]); /** * Handle autocomplete selection */ - const handleAutocompleteSelect = useCallback((filename: string) => { + const handleAutocompleteSelect = useCallback((filename: string, _fullPath?: string) => { if (!autocomplete) return; - const textarea = descriptionRef.current; if (!textarea) return; - // Replace the @query with @filename const beforeMention = description.slice(0, autocomplete.startPos); const afterMention = description.slice(autocomplete.startPos + 1 + autocomplete.query.length); const newDescription = beforeMention + '@' + filename + afterMention; @@ -404,198 +285,36 @@ export function TaskCreationWizard({ setDescription(newDescription); setAutocomplete(null); - // Set cursor after the inserted mention - setTimeout(() => { + // Use queueMicrotask instead of setTimeout - doesn't need cleanup on unmount + queueMicrotask(() => { const newCursorPos = autocomplete.startPos + 1 + filename.length; textarea.focus(); textarea.setSelectionRange(newCursorPos, newCursorPos); - }, 0); + }); }, [autocomplete, description]); /** - * Close autocomplete - */ - const handleAutocompleteClose = useCallback(() => { - setAutocomplete(null); - }, []); - - /** - * Handle drag over the form container to auto-scroll when dragging near edges - */ - const handleContainerDragOver = useCallback((e: DragEvent) => { - const container = formContainerRef.current; - if (!container) return; - - const rect = container.getBoundingClientRect(); - const edgeThreshold = 60; // px from edge to trigger scroll - const scrollSpeed = 8; - - // Auto-scroll when dragging near top or bottom edges - if (e.clientY < rect.top + edgeThreshold) { - container.scrollTop -= scrollSpeed; - } else if (e.clientY > rect.bottom - edgeThreshold) { - container.scrollTop += scrollSpeed; - } - }, []); - - /** - * Handle drag over textarea for image drops - */ - const handleTextareaDragOver = useCallback((e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOverTextarea(true); - }, []); - - /** - * Handle drag leave from textarea - */ - const handleTextareaDragLeave = useCallback((e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOverTextarea(false); - }, []); - - /** - * Handle drop on textarea for file references and images - */ - const handleTextareaDrop = useCallback( - async (e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOverTextarea(false); - - if (isCreating) return; - - // First, check for file reference drops (from the file explorer) - const jsonData = e.dataTransfer?.getData('application/json'); - if (jsonData) { - try { - const data = JSON.parse(jsonData); - if (data.type === 'file-reference' && data.name) { - // Insert @mention at cursor position in the textarea - const textarea = descriptionRef.current; - if (textarea) { - const cursorPos = textarea.selectionStart || 0; - const textBefore = description.substring(0, cursorPos); - const textAfter = description.substring(cursorPos); - - // Insert @mention at cursor position - const mention = `@${data.name}`; - const newDescription = textBefore + mention + textAfter; - setDescription(newDescription); - - // Set cursor after the inserted mention - setTimeout(() => { - textarea.focus(); - const newCursorPos = cursorPos + mention.length; - textarea.setSelectionRange(newCursorPos, newCursorPos); - }, 0); - - return; // Don't process as image - } - } - } catch { - // Not valid JSON, continue to image handling - } - } - - // Fall back to image file handling - 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]); - // Show success feedback - setPasteSuccess(true); - setTimeout(() => setPasteSuccess(false), 2000); - } - }, - [images, isCreating, description] - ); - - /** - * Parse @mentions from description and create ReferencedFile entries - * Merges with existing referencedFiles, avoiding duplicates + * Parse @mentions from description */ const parseFileMentions = useCallback((text: string, existingFiles: ReferencedFile[]): ReferencedFile[] => { - // Match @filename patterns (supports filenames with dots, hyphens, underscores, and path separators) const mentionRegex = /@([\w\-./\\]+\.\w+)/g; const matches = Array.from(text.matchAll(mentionRegex)); - if (matches.length === 0) return existingFiles; - // Create a set of existing file names for quick lookup const existingNames = new Set(existingFiles.map(f => f.name)); - - // Parse mentioned files that aren't already in the list const newFiles: ReferencedFile[] = []; + matches.forEach(match => { const fileName = match[1]; if (!existingNames.has(fileName)) { newFiles.push({ id: crypto.randomUUID(), - path: fileName, // Store relative path from @mention + path: fileName, name: fileName, isDirectory: false, addedAt: new Date() }); - existingNames.add(fileName); // Prevent duplicates within mentions + existingNames.add(fileName); } }); @@ -604,7 +323,7 @@ export function TaskCreationWizard({ const handleCreate = async () => { if (!description.trim()) { - setError('Please provide a description'); + setError(t('tasks:form.errors.descriptionRequired')); return; } @@ -612,48 +331,37 @@ export function TaskCreationWizard({ setError(null); try { - // Parse @mentions from description and merge with referenced files const allReferencedFiles = parseFileMentions(description, referencedFiles); - // Build metadata from selected values - const metadata: TaskMetadata = { - sourceType: 'manual' - }; - + const metadata: TaskMetadata = { sourceType: 'manual' }; if (category) metadata.category = category; if (priority) metadata.priority = priority; if (complexity) metadata.complexity = complexity; if (impact) metadata.impact = impact; if (model) metadata.model = model; if (thinkingLevel) metadata.thinkingLevel = thinkingLevel; - // All profiles now support per-phase configuration - // isAutoProfile indicates task uses phase-specific models/thinking if (phaseModels && phaseThinking) { - metadata.isAutoProfile = true; + metadata.isAutoProfile = profileId === 'auto'; metadata.phaseModels = phaseModels; metadata.phaseThinking = phaseThinking; } if (images.length > 0) metadata.attachedImages = images; if (allReferencedFiles.length > 0) metadata.referencedFiles = allReferencedFiles; if (requireReviewBeforeCoding) metadata.requireReviewBeforeCoding = true; - // Only include baseBranch if it's not the project default placeholder if (baseBranch && baseBranch !== PROJECT_DEFAULT_BRANCH) metadata.baseBranch = baseBranch; // Pass worktree preference - false means use --direct mode if (!useWorktree) metadata.useWorktree = false; - // Title is optional - if empty, it will be auto-generated by the backend const task = await createTask(projectId, title.trim(), description.trim(), metadata); if (task) { - // Clear draft on successful creation clearDraft(projectId); - // Reset form and close resetForm(); onOpenChange(false); } else { - setError('Failed to create task. Please try again.'); + setError(t('tasks:wizard.errors.createFailed')); } } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); + setError(err instanceof Error ? err.message : t('common:errors.unknownError')); } finally { setIsCreating(false); } @@ -666,7 +374,6 @@ export function TaskCreationWizard({ setPriority(''); setComplexity(''); setImpact(''); - // Reset to selected profile defaults and custom settings setProfileId(settings.selectedAgentProfile || 'auto'); setModel(selectedProfile.model); setThinkingLevel(selectedProfile.thinkingLevel); @@ -678,26 +385,19 @@ export function TaskCreationWizard({ setBaseBranch(PROJECT_DEFAULT_BRANCH); setUseWorktree(true); setError(null); - setShowAdvanced(false); + setShowClassification(false); setShowFileExplorer(false); setShowGitOptions(false); setIsDraftRestored(false); - setPasteSuccess(false); }; - /** - * Handle dialog close - save draft if content exists - */ const handleClose = () => { if (isCreating) return; const draft = getCurrentDraft(); - - // Save draft if there's any content if (!isDraftEmpty(draft)) { saveDraft(draft); } else { - // Clear any existing draft if form is empty clearDraft(projectId); } @@ -705,38 +405,67 @@ export function TaskCreationWizard({ onOpenChange(false); }; - /** - * Discard draft and start fresh - */ const handleDiscardDraft = () => { clearDraft(projectId); resetForm(); setError(null); }; + // Render @ mention highlight overlay for the description textarea + const descriptionOverlay = ( +
+ {description.split(/(@[\w\-./\\]+\.\w+)/g).map((part, i) => { + if (part.match(/^@[\w\-./\\]+\.\w+$/)) { + return ( + + {part} + + ); + } + return {part}; + })} +
+ ); + return ( - - -
- {/* Form content */} -
- -
- Create New Task + setShowFileExplorer(false)} + projectPath={projectPath} + /> + ) + } + sidebarOpen={showFileExplorer} + footer={ +
+
+ {/* Draft restored indicator */} {isDraftRestored && (
- Draft restored + {t('tasks:wizard.draftRestored')}
)} -
- - Describe what you want to build. The AI will analyze your request and - create a detailed specification. - - -
- {/* Description (Primary - Required) */} -
- - {/* Wrap textarea for file @mentions */} -
- {/* Syntax highlight overlay for @mentions */} -
- {description.split(/(@[\w\-./\\]+\.\w+)/g).map((part, i) => { - // Check if this part is an @mention - if (part.match(/^@[\w\-./\\]+\.\w+$/)) { - return ( - - {part} - - ); - } - return {part}; - })} -
-