import { useState, useCallback, useEffect, useRef } from "react"; import type { Task, ModelPreset, Settings, WorkflowStep } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; import { fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, type RefinementType, type ModelInfo } from "../api"; import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { Sparkles, Globe, ChevronUp, ChevronDown, X } from "lucide-react"; const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; export interface PendingImage { file: File; previewUrl: string; } export interface TaskFormProps { mode: "create" | "edit"; // Core fields description: string; onDescriptionChange: (value: string) => void; title?: string; onTitleChange?: (value: string) => void; // Dependencies dependencies: string[]; onDependenciesChange: (deps: string[]) => void; // Model configuration executorModel: string; onExecutorModelChange: (value: string) => void; validatorModel: string; onValidatorModelChange: (value: string) => void; presetMode: "default" | "preset" | "custom"; onPresetModeChange: (mode: "default" | "preset" | "custom") => void; selectedPresetId: string; onSelectedPresetIdChange: (id: string) => void; // Workflow steps selectedWorkflowSteps: string[]; onWorkflowStepsChange: (steps: string[]) => void; // Attachments pendingImages: PendingImage[]; onImagesChange: (images: PendingImage[]) => void; // Context tasks: Task[]; projectId?: string; disabled?: boolean; addToast: (message: string, type?: ToastType) => void; isActive?: boolean; // AI-assisted creation callbacks (create mode only) onPlanningMode?: (initialPlan: string) => void; onSubtaskBreakdown?: (description: string) => void; onClose?: () => void; } export function TaskForm({ mode, description, onDescriptionChange, title, onTitleChange, dependencies, onDependenciesChange, executorModel, onExecutorModelChange, validatorModel, onValidatorModelChange, presetMode, onPresetModeChange, selectedPresetId, onSelectedPresetIdChange, selectedWorkflowSteps, onWorkflowStepsChange, pendingImages, onImagesChange, tasks, projectId, disabled = false, addToast, isActive = true, onPlanningMode, onSubtaskBreakdown, onClose, }: TaskFormProps) { const [showDepDropdown, setShowDepDropdown] = useState(false); const [depSearch, setDepSearch] = useState(""); const [availableModels, setAvailableModels] = useState([]); const [favoriteProviders, setFavoriteProviders] = useState([]); const [favoriteModels, setFavoriteModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [settings, setSettings] = useState(null); const [workflowSteps, setWorkflowSteps] = 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 titleInputRef = useRef(null); const fileInputRef = useRef(null); // Load available models, settings, workflow steps when active useEffect(() => { if (!isActive) return; setModelsLoading(true); fetchModels() .then((response) => { setAvailableModels(response.models); setFavoriteProviders(response.favoriteProviders); setFavoriteModels(response.favoriteModels); }) .catch(() => {/* silently fail */}) .finally(() => setModelsLoading(false)); fetchSettings(projectId) .then((nextSettings) => setSettings(nextSettings)) .catch(() => setSettings(null)); fetchWorkflowSteps(projectId) .then((steps) => setWorkflowSteps(steps.filter((s) => s.enabled))) .catch(() => setWorkflowSteps([])); }, [isActive, projectId]); const availablePresets = settings?.modelPresets || []; const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId); // Auto-select preset by size (create mode only) useEffect(() => { if (mode !== "create" || !isActive || !settings?.autoSelectModelPreset) return; const recommended = getRecommendedPresetForSize(undefined, settings.defaultPresetBySize || {}, availablePresets); if (recommended) { const selection = applyPresetToSelection(recommended); onSelectedPresetIdChange(recommended.id); onPresetModeChange("preset"); onExecutorModelChange(selection.executorValue); onValidatorModelChange(selection.validatorValue); } }, [isActive, settings, availablePresets, mode]); // Auto-focus description (create) or title (edit) when active useEffect(() => { if (!isActive) return; const timeoutId = setTimeout(() => { if (mode === "edit" && titleInputRef.current) { titleInputRef.current.focus(); titleInputRef.current.select(); } else if (mode === "create" && descTextareaRef.current) { descTextareaRef.current.focus(); } }, 0); return () => clearTimeout(timeoutId); }, [isActive, mode]); // 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(); onImagesChange([ ...pendingImages, { file, previewUrl: URL.createObjectURL(file) }, ]); return; } } } }, [pendingImages, onImagesChange]); // 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)) { onImagesChange([ ...pendingImages, { file, previewUrl: URL.createObjectURL(file) }, ]); return; } } }, [pendingImages, onImagesChange]); const removeImage = useCallback((index: number) => { const removed = pendingImages[index]; if (removed) URL.revokeObjectURL(removed.previewUrl); onImagesChange(pendingImages.filter((_, i) => i !== index)); }, [pendingImages, onImagesChange]); const toggleDep = useCallback((id: string) => { onDependenciesChange( dependencies.includes(id) ? dependencies.filter((d) => d !== id) : [...dependencies, id], ); }, [dependencies, onDependenciesChange]); const truncate = (s: string, len: number) => s.length > len ? s.slice(0, len) + "…" : s; // Auto-resize textarea const handleDescriptionInput = useCallback((e: React.ChangeEvent) => { onDescriptionChange(e.target.value); const el = e.target; el.style.height = "auto"; el.style.height = el.scrollHeight + "px"; }, [onDescriptionChange]); // 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); onDescriptionChange(refined); setIsRefineMenuOpen(false); addToast("Description refined with AI", "success"); 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, onDescriptionChange]); 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, favoriteModels }); } catch { setFavoriteProviders(currentFavorites); } }, [favoriteProviders, favoriteModels]); const handleToggleModelFavorite = useCallback(async (modelId: string) => { const currentFavorites = favoriteModels; const isFavorite = currentFavorites.includes(modelId); const newFavorites = isFavorite ? currentFavorites.filter((m) => m !== modelId) : [modelId, ...currentFavorites]; setFavoriteModels(newFavorites); try { await updateGlobalSettings({ favoriteProviders, favoriteModels: newFavorites }); } catch { setFavoriteModels(currentFavorites); } }, [favoriteModels, favoriteProviders]); // Workflow step reorder helpers const moveWorkflowStepUp = useCallback((index: number) => { if (index <= 0) return; const updated = [...selectedWorkflowSteps]; [updated[index - 1], updated[index]] = [updated[index], updated[index - 1]]; onWorkflowStepsChange(updated); }, [selectedWorkflowSteps, onWorkflowStepsChange]); const moveWorkflowStepDown = useCallback((index: number) => { if (index >= selectedWorkflowSteps.length - 1) return; const updated = [...selectedWorkflowSteps]; [updated[index], updated[index + 1]] = [updated[index + 1], updated[index]]; onWorkflowStepsChange(updated); }, [selectedWorkflowSteps, onWorkflowStepsChange]); const removeWorkflowStep = useCallback((stepId: string) => { onWorkflowStepsChange(selectedWorkflowSteps.filter((id) => id !== stepId)); }, [selectedWorkflowSteps, onWorkflowStepsChange]); // Build a lookup for step names (includes both fetched steps and built-in browser-verification) const workflowStepLookup = new Map(); for (const step of workflowSteps) { workflowStepLookup.set(step.id, { name: step.name, description: step.description }); } workflowStepLookup.set("browser-verification", { name: "Browser Verification", description: "Verify web application functionality using browser automation (agent-browser)", }); 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.preventDefault()} onPaste={handlePaste} > {/* Title field (edit mode only) */} {mode === "edit" && onTitleChange && (
onTitleChange(e.target.value)} disabled={disabled} />
)} {/* Description field */}