import "./NewTaskModal.css"; import { useState, useCallback, useEffect, useRef } from "react"; import { DEFAULT_TASK_PRIORITY, type Task, type TaskCreateInput, type TaskPriority } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; import { uploadAttachment, fetchAgents } from "../api"; import type { Agent } from "../api"; import { Bot } from "lucide-react"; import { useSetupReadiness } from "../hooks/useSetupReadiness"; import { SetupWarningBanner } from "./SetupWarningBanner"; import { TaskForm, type PendingImage } from "./TaskForm"; import { useConfirm } from "../hooks/useConfirm"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useNodes } from "../hooks/useNodes"; import { useViewportMode } from "../hooks/useViewportMode"; 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 { confirm } = useConfirm(); const viewportMode = useViewportMode(); useMobileScrollLock(isOpen); const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ enabled: viewportMode === "mobile", }); const keyboardStyle: React.CSSProperties = keyboardOpen ? ({ "--keyboard-overlap": `${keyboardOverlap}px`, "--vv-offset-top": `${viewportOffsetTop}px`, ...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}), } as React.CSSProperties) : {}; const [description, setDescription] = useState(""); const [dependencies, setDependencies] = useState([]); const [pendingImages, setPendingImages] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [executorModel, setExecutorModel] = useState(""); const [validatorModel, setValidatorModel] = useState(""); const [planningModel, setPlanningModel] = useState(""); const [thinkingLevel, setThinkingLevel] = useState(""); const [selectedPresetId, setSelectedPresetId] = useState(""); const [presetMode, setPresetMode] = useState<"default" | "preset" | "custom">("default"); const [hasDirtyState, setHasDirtyState] = useState(false); const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState([]); const [workflowStepsExplicitlySet, setWorkflowStepsExplicitlySet] = useState(false); const [reviewLevel, setReviewLevel] = useState(undefined); const [priority, setPriority] = useState(DEFAULT_TASK_PRIORITY); const [nodeId, setNodeId] = useState(undefined); // Agent assignment state const [selectedAgentId, setSelectedAgentId] = useState(null); const [agents, setAgents] = useState([]); const [showAgentPicker, setShowAgentPicker] = useState(false); const [agentsLoading, setAgentsLoading] = useState(false); const agentPickerRef = useRef(null); // Quick-fields dependency picker state const [showDeps, setShowDeps] = useState(false); const [depSearch, setDepSearch] = useState(""); const quickFieldsDepRef = useRef(null); const { hasAiProvider, hasGithub, loading: setupReadinessLoading } = useSetupReadiness(projectId); const { nodes } = useNodes(); // Handler for workflow step changes that detects explicit user interaction const handleWorkflowStepsChange = useCallback((steps: string[]) => { setWorkflowStepsExplicitlySet(true); setSelectedWorkflowSteps(steps); }, []); // Callback when defaultOn steps are auto-applied by TaskForm const handleDefaultOnApplied = useCallback(() => { // defaultOn auto-selection is not "explicit" user interaction setWorkflowStepsExplicitlySet(false); }, []); // Load agents for agent picker const loadAgents = useCallback(async () => { if (agents.length > 0) { setShowAgentPicker(true); return; } setAgentsLoading(true); try { const result = await fetchAgents(undefined, projectId); setAgents(result); setShowAgentPicker(true); } catch (err) { const msg = getErrorMessage(err); addToast(msg ? `Failed to load agents: ${msg}` : "Failed to load agents", "error"); setShowAgentPicker(false); } finally { setAgentsLoading(false); } }, [agents.length, projectId, addToast]); // Close agent picker when clicking outside useEffect(() => { if (!showAgentPicker) return; const handleClickOutside = (e: MouseEvent) => { if (agentPickerRef.current && !agentPickerRef.current.contains(e.target as Node)) { setShowAgentPicker(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [showAgentPicker]); // Close quick-fields dep dropdown when clicking outside useEffect(() => { if (!showDeps) return; const handleClickOutside = (e: MouseEvent) => { if (quickFieldsDepRef.current && !quickFieldsDepRef.current.contains(e.target as Node)) { setShowDeps(false); setDepSearch(""); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [showDeps]); // Compute available deps for quick-fields picker (same logic as TaskForm) 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; const truncate = (s: string, len: number) => s.length > len ? s.slice(0, len) + "…" : s; // Track dirty state useEffect(() => { const isDirty = description.trim() !== "" || dependencies.length > 0 || pendingImages.length > 0 || executorModel !== "" || validatorModel !== "" || planningModel !== "" || thinkingLevel !== "" || selectedWorkflowSteps.length > 0 || selectedAgentId !== null || reviewLevel !== undefined || priority !== DEFAULT_TASK_PRIORITY || nodeId !== undefined; setHasDirtyState(isDirty); }, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel, priority, nodeId]); const handleClose = useCallback(async () => { if (hasDirtyState) { const shouldDiscard = await confirm({ title: "Discard Changes", message: "You have unsaved changes. Discard them?", danger: true, }); if (!shouldDiscard) return; } // Clean up object URLs pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); // Reset form setPendingImages([]); setDescription(""); setDependencies([]); setExecutorModel(""); setValidatorModel(""); setPlanningModel(""); setThinkingLevel(""); setSelectedPresetId(""); setPresetMode("default"); setSelectedWorkflowSteps([]); setWorkflowStepsExplicitlySet(false); setSelectedAgentId(null); setShowAgentPicker(false); setReviewLevel(undefined); setPriority(DEFAULT_TASK_PRIORITY); setNodeId(undefined); setHasDirtyState(false); onClose(); }, [hasDirtyState, onClose, pendingImages, confirm]); const handleSubmit = useCallback(async () => { const trimmedDesc = description.trim(); if (!trimmedDesc || isSubmitting) return; setIsSubmitting(true); try { const executorSlashIdx = executorModel.indexOf("/"); const validatorSlashIdx = validatorModel.indexOf("/"); const planningSlashIdx = planningModel.indexOf("/"); const task = await onCreateTask({ title: undefined, description: trimmedDesc, column: "triage", dependencies: dependencies.length ? dependencies : undefined, // When user explicitly cleared all workflow steps, send empty array to prevent backend re-applying defaults. // When user hasn't interacted with workflow steps (or left auto-selected defaults), send undefined to let backend apply defaults. enabledWorkflowSteps: workflowStepsExplicitlySet ? (selectedWorkflowSteps.length > 0 ? selectedWorkflowSteps : []) : undefined, ...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}), 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, planningModelProvider: planningModel && planningSlashIdx !== -1 ? planningModel.slice(0, planningSlashIdx) : undefined, planningModelId: planningModel && planningSlashIdx !== -1 ? planningModel.slice(planningSlashIdx + 1) : undefined, thinkingLevel: thinkingLevel !== "" ? thinkingLevel as "minimal" | "low" | "medium" | "high" : undefined, reviewLevel, priority, nodeId, }); // 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(""); setPlanningModel(""); setThinkingLevel(""); setSelectedPresetId(""); setPresetMode("default"); setSelectedWorkflowSteps([]); setWorkflowStepsExplicitlySet(false); setSelectedAgentId(null); setShowAgentPicker(false); setReviewLevel(undefined); setPriority(DEFAULT_TASK_PRIORITY); setNodeId(undefined); addToast(`Created ${task.id}`, "success"); onClose(); } catch (err) { addToast(getErrorMessage(err) || "Failed to create task", "error"); } finally { setIsSubmitting(false); } }, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet, selectedAgentId, reviewLevel, priority, nodeId]); // Handle keyboard shortcuts const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); handleClose(); } }, [handleClose]); // Compute selected agent label for display const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined; const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId; // Quick fields: promoted dependencies and agent assignment const quickFields = (
{/* Dependencies field */}
{showDeps && (
setDepSearch(e.target.value)} onClick={(e) => e.stopPropagation()} /> {filteredDeps.length === 0 ? (
No available tasks
) : ( filteredDeps.map((t) => (
{ setDependencies( dependencies.includes(t.id) ? dependencies.filter((d) => d !== t.id) : [...dependencies, t.id], ); setShowDeps(false); setDepSearch(""); }} onMouseDown={(e) => e.preventDefault()} > {t.id} {truncate(t.title || t.description || t.id, 30)}
)) )}
)}
{dependencies.length > 0 && (
{dependencies.map((depId) => ( {depId} ))}
)}
{/* Agent Assignment */}
{showAgentPicker && (
e.preventDefault()}>
Select agent
{agentsLoading &&
Loading agents...
} {!agentsLoading && agents.filter((a) => a.state !== "terminated").map((a) => (
e.preventDefault()} onClick={() => { setSelectedAgentId(a.id === selectedAgentId ? null : a.id); setShowAgentPicker(false); }} data-testid={`agent-option-${a.id}`} > {a.role} {a.name}
))} {!agentsLoading && agents.filter((a) => a.state !== "terminated").length === 0 && (
No agents available
)} {selectedAgentId && (
e.preventDefault()} onClick={() => { setSelectedAgentId(null); setShowAgentPicker(false); }} > Clear selection
)}
)}
); if (!isOpen) return null; return (
e.stopPropagation()} style={keyboardStyle} >

New Task

{!setupReadinessLoading && ( )}
); }