import { useState, useCallback, useEffect, useRef } from "react"; import type { Task, TaskCreateInput, ModelPreset, Settings, WorkflowStep } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; import { uploadAttachment, fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, type RefinementType } from "../api"; import type { ModelInfo } from "../api"; import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { Sparkles, Globe } from "lucide-react"; const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; interface PendingImage { file: File; previewUrl: string; } interface NewTaskModalProps { isOpen: boolean; onClose: () => void; projectId?: string; tasks: Task[]; // for dependency selection onCreateTask: (input: TaskCreateInput) => Promise; addToast: (message: string, type?: ToastType) => void; onPlanningMode?: (initialPlan: string) => void; onSubtaskBreakdown?: (description: string) => void; } export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast, onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) { const [description, setDescription] = useState(""); const [dependencies, setDependencies] = useState([]); const [showDepDropdown, setShowDepDropdown] = useState(false); const [depSearch, setDepSearch] = useState(""); const [pendingImages, setPendingImages] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [availableModels, setAvailableModels] = useState([]); const [favoriteProviders, setFavoriteProviders] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [executorModel, setExecutorModel] = useState(""); const [validatorModel, setValidatorModel] = useState(""); const [settings, setSettings] = useState(null); const [selectedPresetId, setSelectedPresetId] = useState(""); const [presetMode, setPresetMode] = useState<"default" | "preset" | "custom">("default"); const [hasDirtyState, setHasDirtyState] = useState(false); const [workflowSteps, setWorkflowSteps] = useState([]); const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState([]); // AI Refinement state const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false); const [isRefining, setIsRefining] = useState(false); const refineMenuRef = useRef(null); const depDropdownRef = useRef(null); const descTextareaRef = useRef(null); const fileInputRef = useRef(null); // Load available models when modal opens useEffect(() => { if (isOpen) { setModelsLoading(true); fetchModels() .then((response) => { setAvailableModels(response.models); setFavoriteProviders(response.favoriteProviders); }) .catch(() => {/* silently fail - models just won't be available */}) .finally(() => setModelsLoading(false)); fetchSettings(projectId) .then((nextSettings) => setSettings(nextSettings)) .catch(() => setSettings(null)); fetchWorkflowSteps(projectId) .then((steps) => setWorkflowSteps(steps.filter((s) => s.enabled))) .catch(() => setWorkflowSteps([])); } }, [isOpen, projectId]); // Track dirty state useEffect(() => { const isDirty = description.trim() !== "" || dependencies.length > 0 || pendingImages.length > 0 || executorModel !== "" || validatorModel !== "" || selectedWorkflowSteps.length > 0; setHasDirtyState(isDirty); }, [description, dependencies, pendingImages, executorModel, validatorModel, selectedWorkflowSteps]); const availablePresets = settings?.modelPresets || []; const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId); useEffect(() => { if (!isOpen || !settings?.autoSelectModelPreset) return; const recommended = getRecommendedPresetForSize(undefined, settings.defaultPresetBySize || {}, availablePresets); if (recommended) { const selection = applyPresetToSelection(recommended); setSelectedPresetId(recommended.id); setPresetMode("preset"); setExecutorModel(selection.executorValue); setValidatorModel(selection.validatorValue); } }, [isOpen, settings, availablePresets]); // Auto-focus description textarea when modal opens useEffect(() => { if (isOpen) { // Small delay to ensure modal is fully rendered const timeoutId = setTimeout(() => { descTextareaRef.current?.focus(); }, 0); return () => clearTimeout(timeoutId); } }, [isOpen]); // Close dropdown when clicking outside useEffect(() => { if (!showDepDropdown) return; const handleClickOutside = (e: MouseEvent) => { if (depDropdownRef.current && !depDropdownRef.current.contains(e.target as Node)) { setShowDepDropdown(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [showDepDropdown]); // Close refine menu when clicking outside useEffect(() => { if (!isRefineMenuOpen) return; const handleClickOutside = (e: MouseEvent) => { if (refineMenuRef.current && !refineMenuRef.current.contains(e.target as Node)) { setIsRefineMenuOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [isRefineMenuOpen]); // Handle paste for images const handlePaste = useCallback((e: React.ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.type.startsWith("image/")) { const file = item.getAsFile(); if (file && ALLOWED_IMAGE_TYPES.includes(file.type)) { e.preventDefault(); setPendingImages((prev) => [ ...prev, { file, previewUrl: URL.createObjectURL(file) }, ]); return; } } } }, []); // Handle file drop for images const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); const files = e.dataTransfer.files; for (let i = 0; i < files.length; i++) { const file = files[i]; if (ALLOWED_IMAGE_TYPES.includes(file.type)) { setPendingImages((prev) => [ ...prev, { file, previewUrl: URL.createObjectURL(file) }, ]); return; } } }, []); const removeImage = useCallback((index: number) => { setPendingImages((prev) => { const removed = prev[index]; if (removed) URL.revokeObjectURL(removed.previewUrl); return prev.filter((_, i) => i !== index); }); }, []); const toggleDep = useCallback((id: string) => { setDependencies((prev) => prev.includes(id) ? prev.filter((d) => d !== id) : [...prev, id], ); }, []); const truncate = (s: string, len: number) => s.length > len ? s.slice(0, len) + "…" : s; const handleClose = useCallback(() => { if (hasDirtyState) { if (!confirm("You have unsaved changes. Discard them?")) return; } // Clean up object URLs pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); setPendingImages([]); // Reset form setPendingImages([]); setDescription(""); setDependencies([]); setExecutorModel(""); setValidatorModel(""); setSelectedPresetId(""); setPresetMode("default"); setSelectedWorkflowSteps([]); setIsRefineMenuOpen(false); setIsRefining(false); setHasDirtyState(false); onClose(); }, [hasDirtyState, onClose, pendingImages]); const handleSubmit = useCallback(async () => { const trimmedDesc = description.trim(); if (!trimmedDesc || isSubmitting) return; setIsSubmitting(true); try { // Create the base task const executorSlashIdx = executorModel.indexOf("/"); const validatorSlashIdx = validatorModel.indexOf("/"); const task = await onCreateTask({ title: undefined, description: trimmedDesc, column: "triage", dependencies: dependencies.length ? dependencies : undefined, enabledWorkflowSteps: selectedWorkflowSteps.length > 0 ? selectedWorkflowSteps : undefined, modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined, modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined, modelId: executorModel && executorSlashIdx !== -1 ? executorModel.slice(executorSlashIdx + 1) : undefined, validatorModelProvider: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(0, validatorSlashIdx) : undefined, validatorModelId: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(validatorSlashIdx + 1) : undefined, }); // Upload pending images as attachments if (pendingImages.length > 0) { const failures: string[] = []; for (const img of pendingImages) { try { await uploadAttachment(task.id, img.file, projectId); } catch { failures.push(img.file.name); } } if (failures.length > 0) { addToast(`Failed to upload: ${failures.join(", ")}`, "error"); } } // Clean up pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); setPendingImages([]); setDescription(""); setDependencies([]); setExecutorModel(""); setValidatorModel(""); setSelectedPresetId(""); setPresetMode("default"); setSelectedWorkflowSteps([]); addToast(`Created ${task.id}`, "success"); onClose(); } catch (err: any) { addToast(err.message || "Failed to create task", "error"); } finally { setIsSubmitting(false); } }, [description, dependencies, pendingImages, executorModel, validatorModel, isSubmitting, onCreateTask, addToast, onClose]); // Handle keyboard shortcuts const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === "Escape" && !showDepDropdown) { e.preventDefault(); handleClose(); } }, [handleClose, showDepDropdown]); // Auto-resize textarea const handleDescriptionChange = useCallback((e: React.ChangeEvent) => { setDescription(e.target.value); const el = e.target; el.style.height = "auto"; el.style.height = el.scrollHeight + "px"; }, []); // AI Refinement handler const handleRefine = useCallback(async (type: RefinementType) => { const trimmed = description.trim(); if (!trimmed || isRefining) return; setIsRefining(true); try { const refined = await refineText(trimmed, type); setDescription(refined); setIsRefineMenuOpen(false); addToast("Description refined with AI", "success"); // Auto-resize textarea after content update if (descTextareaRef.current) { descTextareaRef.current.style.height = "auto"; descTextareaRef.current.style.height = descTextareaRef.current.scrollHeight + "px"; } } catch (err: any) { const errorMessage = getRefineErrorMessage(err); addToast(errorMessage, "error"); } finally { setIsRefining(false); } }, [description, isRefining, addToast]); const handleToggleFavorite = useCallback(async (provider: string) => { const currentFavorites = favoriteProviders; const isFavorite = currentFavorites.includes(provider); const newFavorites = isFavorite ? currentFavorites.filter((p) => p !== provider) : [provider, ...currentFavorites]; setFavoriteProviders(newFavorites); try { await updateGlobalSettings({ favoriteProviders: newFavorites }); } catch { // Revert on error setFavoriteProviders(currentFavorites); } }, [favoriteProviders]); if (!isOpen) return null; const availableDeps = tasks .filter((t) => !dependencies.includes(t.id)) .sort((a, b) => { const cmp = b.createdAt.localeCompare(a.createdAt); if (cmp !== 0) return cmp; const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0; const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0; return bNum - aNum; }); const filteredDeps = depSearch ? availableDeps.filter((t) => t.id.toLowerCase().includes(depSearch.toLowerCase()) || (t.title && t.title.toLowerCase().includes(depSearch.toLowerCase())) || (t.description && t.description.toLowerCase().includes(depSearch.toLowerCase())) ) : availableDeps; return (
e.stopPropagation()} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()} onPaste={handlePaste} >

New Task

{/* Description field */}