import "./NewTaskModal.css"; import { useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; 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 } from "../api"; import { Bot } from "lucide-react"; import { useSetupReadiness } from "../hooks/useSetupReadiness"; import { SetupWarningBanner } from "./SetupWarningBanner"; import { TaskForm, type BranchSelectionMode, type PendingImage } from "./TaskForm"; import { REPO_OVERRIDE_RE } from "./githubTracking"; 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"; import { useAgentsMapCache } from "../hooks/useAgentsMapCache"; interface NewTaskModalProps { isOpen: boolean; onClose: () => void; projectId?: string; tasks: Task[]; // for dependency selection onCreateTask: (input: TaskCreateInput) => Promise; addToast: (message: string, type?: ToastType) => void; } export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast }: NewTaskModalProps) { const { t } = useTranslation("app"); 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 [branchMode, setBranchMode] = useState("project-default"); const [branch, setBranch] = useState(""); const [baseBranch, setBaseBranch] = 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); // U6/R3: tri-state workflow selection. `undefined` = inherit project default, // `null` = explicit "No workflow", `string` = a specific workflow. Materialized // atomically at create time via the `workflowId` create parameter. const [selectedWorkflowId, setSelectedWorkflowId] = useState(undefined); const [reviewLevel, setReviewLevel] = useState(undefined); const [autoMerge, setAutoMerge] = useState(undefined); const [priority, setPriority] = useState(DEFAULT_TASK_PRIORITY); const [nodeId, setNodeId] = useState(undefined); const [githubTrackingEnabled, setGithubTrackingEnabled] = useState(false); const [githubRepoOverride, setGithubRepoOverride] = useState(""); // Agent assignment state const [selectedAgentId, setSelectedAgentId] = useState(null); const { agents, loading: agentsLoading } = useAgentsMapCache(projectId); const [showAgentPicker, setShowAgentPicker] = 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(); // Load agents for agent picker const loadAgents = useCallback(() => { setShowAgentPicker(true); }, []); // 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; const githubRepoOverrideTrimmed = githubRepoOverride.trim(); const githubRepoOverrideInvalid = githubRepoOverrideTrimmed.length > 0 && !REPO_OVERRIDE_RE.test(githubRepoOverrideTrimmed); const isBranchNameRequired = branchMode === "existing" || branchMode === "custom-new" || branchMode === "shared-group"; const hasInvalidBranchSelection = isBranchNameRequired && !branch.trim(); // Track dirty state useEffect(() => { const isDirty = description.trim() !== "" || dependencies.length > 0 || pendingImages.length > 0 || selectedWorkflowId !== undefined || executorModel !== "" || validatorModel !== "" || planningModel !== "" || thinkingLevel !== "" || selectedAgentId !== null || reviewLevel !== undefined || autoMerge !== undefined || priority !== DEFAULT_TASK_PRIORITY || nodeId !== undefined || branchMode !== "project-default" || branch !== "" || baseBranch !== "" || githubTrackingEnabled || githubRepoOverrideTrimmed !== ""; setHasDirtyState(isDirty); }, [description, dependencies, pendingImages, selectedWorkflowId, executorModel, validatorModel, planningModel, thinkingLevel, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]); const handleClose = useCallback(async () => { if (hasDirtyState) { const shouldDiscard = await confirm({ title: t("newTaskModal.discardChanges", "Discard Changes"), message: t("newTaskModal.unsavedChanges", "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"); setSelectedWorkflowId(undefined); setSelectedAgentId(null); setShowAgentPicker(false); setReviewLevel(undefined); setAutoMerge(undefined); setPriority(DEFAULT_TASK_PRIORITY); setNodeId(undefined); setBranchMode("project-default"); setBranch(""); setBaseBranch(""); setHasDirtyState(false); setGithubTrackingEnabled(false); setGithubRepoOverride(""); onClose(); }, [hasDirtyState, onClose, pendingImages, confirm, t]); const handleSubmit = useCallback(async () => { const trimmedDesc = description.trim(); if (!trimmedDesc || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection) return; setIsSubmitting(true); try { const executorSlashIdx = executorModel.indexOf("/"); const validatorSlashIdx = validatorModel.indexOf("/"); const planningSlashIdx = planningModel.indexOf("/"); const createInput: TaskCreateInput & { branchSelection?: { mode: BranchSelectionMode; branchName?: string; baseBranch?: string; }; } = { title: undefined, description: trimmedDesc, column: "triage", dependencies: dependencies.length ? dependencies : undefined, // U6/R3: forward the workflow selection only when the user changed it. // - undefined → omit (store inherits the project default, today's behavior) // - null → explicit "No workflow" (store skips default materialization) // - string → that workflow, materialized atomically at create time. ...(selectedWorkflowId !== undefined ? { workflowId: selectedWorkflowId } : {}), ...(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, ...(autoMerge !== undefined ? { autoMerge } : {}), priority, nodeId, branchSelection: { mode: branchMode, ...(isBranchNameRequired && branch.trim() ? { branchName: branch.trim() } : {}), ...(baseBranch.trim() ? { baseBranch: baseBranch.trim() } : {}), }, ...(githubTrackingEnabled || githubRepoOverrideTrimmed !== "" ? { githubTracking: { enabled: githubTrackingEnabled, ...(githubRepoOverrideTrimmed !== "" ? { repoOverride: githubRepoOverrideTrimmed } : {}), }, } : {}), }; // U6/R3: the workflow is now materialized atomically inside createTask via // the `workflowId` parameter — no post-create selectTaskWorkflow call, so // the executor can never observe the task with the wrong step set. const task = await onCreateTask(createInput); // 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(t("newTaskModal.failedToUpload", "Failed to upload: {{files}}", { files: failures.join(", ") }), "error"); } } // Clean up pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); setPendingImages([]); setDescription(""); setDependencies([]); setExecutorModel(""); setValidatorModel(""); setPlanningModel(""); setThinkingLevel(""); setSelectedPresetId(""); setPresetMode("default"); setSelectedWorkflowId(undefined); setSelectedAgentId(null); setShowAgentPicker(false); setReviewLevel(undefined); setAutoMerge(undefined); setPriority(DEFAULT_TASK_PRIORITY); setNodeId(undefined); setBranchMode("project-default"); setBranch(""); setBaseBranch(""); addToast(t("newTaskModal.taskCreated", "Created {{taskId}}", { taskId: task.id }), "success"); onClose(); } catch (err) { addToast(getErrorMessage(err) || t("newTaskModal.failedToCreate", "Failed to create task"), "error"); } finally { setIsSubmitting(false); } }, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowId, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, t]); // 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 ? (
{t("newTaskModal.noAvailableTasks", "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()}>
{t("newTaskModal.selectAgent", "Select agent")}
{agentsLoading &&
{t("newTaskModal.loadingAgents", "Loading agents...")}
} {!agentsLoading && agents.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.length === 0 && (
{t("newTaskModal.noAgentsAvailable", "No agents available")}
)} {selectedAgentId && (
e.preventDefault()} onClick={() => { setSelectedAgentId(null); setShowAgentPicker(false); }} > {t("newTaskModal.clearSelection", "Clear selection")}
)}
)}
{/* U6/R3: the workflow picker now lives inside TaskForm (a whole-workflow dropdown materialized atomically at create time), replacing the prior standalone WorkflowSelector + post-create selectTaskWorkflow flow. */}
); if (!isOpen) return null; return (
e.stopPropagation()} style={keyboardStyle} >

{t("newTaskModal.title", "New Task")}

{!setupReadinessLoading && ( )}
{hasInvalidBranchSelection && (
{t("newTaskModal.branchRequired", "Branch name is required for this branch strategy.")}
)}
); }