import { useState, useCallback, useEffect, useRef, type ReactNode } from "react"; import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type GlobalSettings, type Task, type TaskPriority, type Settings, type WorkflowStep } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; import { fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, fetchGlobalSettings, fetchGitBranches, type RefinementType, type ModelInfo, type NodeInfo } from "../api"; import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { NodeHealthDot } from "./NodeHealthDot"; import { Sparkles, ChevronUp, ChevronDown, X, Maximize2, Minimize2 } from "lucide-react"; import { REPO_OVERRIDE_RE, resolveEffectiveGithubRepoDefault } from "./githubTracking"; function getNodeStatusLabel(status: NodeInfo["status"]): string { if (status === "online") return "Online"; if (status === "connecting") return "Connecting"; if (status === "error") return "Error"; return "Offline"; } const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; const COMMON_INTEGRATION_BRANCHES = ["main", "master", "trunk", "develop"]; const CUSTOM_BRANCH_OPTION = "__fusion-custom__"; const DEFAULT_BRANCH_OPTION = ""; function sortBranchNames(branches: string[]): string[] { const seen = new Set(); const ordered: string[] = []; for (const name of COMMON_INTEGRATION_BRANCHES) { if (branches.includes(name) && !seen.has(name)) { ordered.push(name); seen.add(name); } } for (const name of [...branches].sort((a, b) => a.localeCompare(b))) { if (seen.has(name)) continue; seen.add(name); ordered.push(name); } return ordered; } /** Renders a phase badge using shared .phase-badge classes for consistency */ function phaseBadge(phase: "pre-merge" | "post-merge", id: string, prefix: string): ReactNode { const phaseClass = phase === "post-merge" ? "phase-badge--post-merge" : "phase-badge--pre-merge"; return ( {phase === "post-merge" ? "Post-merge" : "Pre-merge"} ); } export interface PendingImage { file: File; previewUrl: string; } type TaskExecutionModeSelection = "standard" | "fast"; export type BranchSelectionMode = "project-default" | "auto-new" | "existing" | "custom-new" | "shared-group"; 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; branch?: string; onBranchChange?: (value: string) => void; branchMode?: BranchSelectionMode; onBranchModeChange?: (value: BranchSelectionMode) => void; baseBranch?: string; onBaseBranchChange?: (value: string) => void; nodeId?: string; onNodeIdChange?: (nodeId: string | undefined) => void; nodeOptions?: NodeInfo[]; nodeOverrideDisabled?: boolean; nodeOverrideDisabledReason?: string; // Model configuration priority?: TaskPriority; onPriorityChange?: (value: TaskPriority) => void; executorModel: string; onExecutorModelChange: (value: string) => void; validatorModel: string; onValidatorModelChange: (value: string) => void; planningModel?: string; onPlanningModelChange?: (value: string) => void; thinkingLevel?: string; onThinkingLevelChange?: (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; /** Callback fired when defaultOn steps have been preselected (create mode). Parent can use this to distinguish "no selection yet" from "user explicitly cleared". */ onDefaultOnApplied?: (stepIds: string[]) => void; // Attachments pendingImages: PendingImage[]; onImagesChange: (images: PendingImage[]) => void; // Context tasks: Task[]; projectId?: string; disabled?: boolean; addToast: (message: string, type?: ToastType) => void; isActive?: boolean; // Auto-save callback (edit mode) onAutoSaveDescription?: (description: string) => Promise; // Review level (0=None, 1=Plan Only, 2=Plan and Code, 3=Full) reviewLevel?: number; onReviewLevelChange?: (value: number | undefined) => void; autoMerge?: boolean | undefined; onAutoMergeChange?: (value: boolean | undefined) => void; executionMode?: TaskExecutionModeSelection; onExecutionModeChange?: (value: TaskExecutionModeSelection) => void; githubTrackingEnabled?: boolean; onGithubTrackingEnabledChange?: (value: boolean) => void; githubRepoOverride?: string; onGithubRepoOverrideChange?: (value: string) => void; // AI-assisted creation callbacks (create mode only) onPlanningMode?: (initialPlan: string) => void; onSubtaskBreakdown?: (description: string) => void; onClose?: () => void; /** Optional content to render between the primary section and the "More options" toggle. */ renderBelowPrimary?: React.ReactNode; /** Optional content to render inside "More options" below Model Configuration. */ renderBelowModelConfiguration?: React.ReactNode; /** When true, skip rendering the Dependencies form-group inside "More options". Use when the parent renders its own dependency UI via renderBelowPrimary. */ hideDependencies?: boolean; /** When true (default), More options auto-expands when non-default advanced selections are present. */ autoExpandMoreOptionsOnSelection?: boolean; } export function TaskForm({ mode, description, onDescriptionChange, title, onTitleChange, dependencies, onDependenciesChange, branch, onBranchChange, branchMode, onBranchModeChange, baseBranch, onBaseBranchChange, nodeId, onNodeIdChange, nodeOptions, nodeOverrideDisabled = false, nodeOverrideDisabledReason, priority, onPriorityChange, executorModel, onExecutorModelChange, validatorModel, onValidatorModelChange, planningModel, onPlanningModelChange, thinkingLevel, onThinkingLevelChange, presetMode, onPresetModeChange, selectedPresetId, onSelectedPresetIdChange, selectedWorkflowSteps, onWorkflowStepsChange, onDefaultOnApplied, pendingImages, onImagesChange, tasks, projectId, disabled = false, addToast, isActive = true, onAutoSaveDescription, onPlanningMode, onSubtaskBreakdown, onClose, renderBelowPrimary, renderBelowModelConfiguration, hideDependencies, autoExpandMoreOptionsOnSelection = true, reviewLevel, onReviewLevelChange, autoMerge, onAutoMergeChange, executionMode, onExecutionModeChange, githubTrackingEnabled, onGithubTrackingEnabledChange, githubRepoOverride, onGithubRepoOverrideChange, }: TaskFormProps) { const hasInitialMoreOptions = (hideDependencies ? false : dependencies.length > 0) || pendingImages.length > 0 || selectedWorkflowSteps.length > 0 || presetMode !== "default" || (priority ?? DEFAULT_TASK_PRIORITY) !== DEFAULT_TASK_PRIORITY || executorModel !== "" || validatorModel !== "" || (planningModel || "") !== "" || (thinkingLevel || "") !== "" || reviewLevel !== undefined || autoMerge !== undefined || executionMode === "fast" || (branch || "") !== "" || (baseBranch || "") !== "" || (nodeId || "") !== "" || githubTrackingEnabled === true || (githubRepoOverride || "") !== ""; const [showDepDropdown, setShowDepDropdown] = useState(false); const [showMoreOptions, setShowMoreOptions] = useState( autoExpandMoreOptionsOnSelection ? hasInitialMoreOptions : 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 [globalSettings, setGlobalSettings] = useState(null); const [workflowSteps, setWorkflowSteps] = useState([]); const [autoSaveStatus, setAutoSaveStatus] = useState<"idle" | "saving" | "saved">("idle"); const [baseBranchOptions, setBaseBranchOptions] = useState([]); const [baseBranchCustomMode, setBaseBranchCustomMode] = useState(false); // AI Refinement state const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false); const [isRefining, setIsRefining] = useState(false); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const refineMenuRef = useRef(null); const depDropdownRef = useRef(null); const descTextareaRef = useRef(null); const titleInputRef = useRef(null); const fileInputRef = useRef(null); const autoSaveTimeoutRef = useRef | null>(null); const autoSaveStatusTimeoutRef = useRef | null>(null); const isAutoSavingRef = useRef(false); const hadMoreOptionSelectionsRef = useRef(hasInitialMoreOptions); const initialDescriptionRef = useRef(description.trim()); const lastAutoSavedDescriptionRef = useRef(description.trim()); // 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([])); fetchGlobalSettings() .then((nextGlobalSettings) => setGlobalSettings(nextGlobalSettings)) .catch(() => setGlobalSettings(null)); }, [isActive, projectId]); const availablePresets = settings?.modelPresets || []; const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId); const effectiveGithubRepoDefault = resolveEffectiveGithubRepoDefault(settings, globalSettings); const githubRepoOverrideTrimmed = (githubRepoOverride || "").trim(); const githubRepoOverrideInvalid = githubRepoOverrideTrimmed.length > 0 && !REPO_OVERRIDE_RE.test(githubRepoOverrideTrimmed); const hasMoreOptionSelections = (hideDependencies ? false : dependencies.length > 0) || pendingImages.length > 0 || selectedWorkflowSteps.length > 0 || presetMode !== "default" || (priority ?? DEFAULT_TASK_PRIORITY) !== DEFAULT_TASK_PRIORITY || executorModel !== "" || validatorModel !== "" || (planningModel || "") !== "" || (thinkingLevel || "") !== "" || reviewLevel !== undefined || autoMerge !== undefined || executionMode === "fast" || (branch || "") !== "" || (baseBranch || "") !== "" || (nodeId || "") !== "" || githubTrackingEnabled === true || (githubRepoOverride || "") !== ""; // 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-select defaultOn workflow steps (create mode, once per activation) const defaultOnAppliedRef = useRef(false); const githubTrackingDefaultAppliedRef = useRef(false); useEffect(() => { if (mode !== "create" || !isActive) return; if (defaultOnAppliedRef.current) return; if (workflowSteps.length === 0) return; const defaultOnSteps = workflowSteps.filter((s) => s.defaultOn); if (defaultOnSteps.length === 0) return; defaultOnAppliedRef.current = true; const stepIds = defaultOnSteps.map((s) => s.id); onWorkflowStepsChange(stepIds); onDefaultOnApplied?.(stepIds); }, [mode, isActive, workflowSteps]); // Reset defaultOn tracking when form deactivates useEffect(() => { if (!isActive) { defaultOnAppliedRef.current = false; } }, [isActive]); useEffect(() => { if (mode !== "create" || !isActive) return; if (!onGithubTrackingEnabledChange) return; if (githubTrackingDefaultAppliedRef.current) return; if (!settings) return; onGithubTrackingEnabledChange(settings.githubTrackingEnabledByDefault ?? false); githubTrackingDefaultAppliedRef.current = true; }, [mode, isActive, settings, onGithubTrackingEnabledChange]); useEffect(() => { if (!isActive || !onBaseBranchChange) return; fetchGitBranches(projectId) .then((branches) => { const names = branches .map((branchInfo) => branchInfo.name) .filter((name): name is string => typeof name === "string" && name.length > 0); setBaseBranchOptions(sortBranchNames(names)); }) .catch(() => setBaseBranchOptions([])); }, [isActive, onBaseBranchChange, projectId]); useEffect(() => { if (!isActive) { githubTrackingDefaultAppliedRef.current = false; } }, [isActive]); // Auto-expand advanced options when non-default values are present. useEffect(() => { if (!autoExpandMoreOptionsOnSelection) { hadMoreOptionSelectionsRef.current = hasMoreOptionSelections; return; } if (hasMoreOptionSelections && !hadMoreOptionSelectionsRef.current) { setShowMoreOptions(true); } hadMoreOptionSelectionsRef.current = hasMoreOptionSelections; }, [hasMoreOptionSelections, autoExpandMoreOptionsOnSelection]); // Keep dependency dropdown state clean when advanced options are collapsed. useEffect(() => { if (showMoreOptions) return; setShowDepDropdown(false); setDepSearch(""); }, [showMoreOptions]); // Auto-select title input text in edit mode (focus is handled by autoFocus) useEffect(() => { if (mode !== "edit" || !isActive) return; if (titleInputRef.current) { titleInputRef.current.focus(); titleInputRef.current.select(); } }, [mode, isActive]); // 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]); // Exit description fullscreen mode when edit controls are unavailable useEffect(() => { if (mode !== "edit" || disabled) { setIsDescriptionExpanded(false); } }, [mode, disabled]); // Reset auto-save tracking when entering edit mode useEffect(() => { if (mode !== "edit") { setAutoSaveStatus("idle"); return; } const trimmed = description.trim(); initialDescriptionRef.current = trimmed; lastAutoSavedDescriptionRef.current = trimmed; setAutoSaveStatus("idle"); }, [mode]); // Debounced auto-save for edit mode description changes useEffect(() => { if (mode !== "edit" || !onAutoSaveDescription || !isActive) return; const trimmedDescription = description.trim(); const initialDescription = initialDescriptionRef.current; if (trimmedDescription === initialDescription || trimmedDescription === lastAutoSavedDescriptionRef.current) { if (autoSaveTimeoutRef.current) { clearTimeout(autoSaveTimeoutRef.current); autoSaveTimeoutRef.current = null; } if (!isAutoSavingRef.current) { setAutoSaveStatus("idle"); } return; } if (autoSaveTimeoutRef.current) { clearTimeout(autoSaveTimeoutRef.current); } autoSaveTimeoutRef.current = setTimeout(async () => { if (isAutoSavingRef.current) return; isAutoSavingRef.current = true; setAutoSaveStatus("saving"); try { await onAutoSaveDescription(trimmedDescription); lastAutoSavedDescriptionRef.current = trimmedDescription; setAutoSaveStatus("saved"); if (autoSaveStatusTimeoutRef.current) { clearTimeout(autoSaveStatusTimeoutRef.current); } autoSaveStatusTimeoutRef.current = setTimeout(() => { setAutoSaveStatus("idle"); autoSaveStatusTimeoutRef.current = null; }, 2000); } catch { setAutoSaveStatus("idle"); } finally { isAutoSavingRef.current = false; autoSaveTimeoutRef.current = null; } }, 1500); return () => { if (autoSaveTimeoutRef.current) { clearTimeout(autoSaveTimeoutRef.current); autoSaveTimeoutRef.current = null; } }; }, [mode, description, onAutoSaveDescription, isActive]); useEffect(() => { return () => { if (autoSaveTimeoutRef.current) { clearTimeout(autoSaveTimeoutRef.current); } if (autoSaveStatusTimeoutRef.current) { clearTimeout(autoSaveStatusTimeoutRef.current); } }; }, []); // 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]); const handleToggleDescriptionExpand = useCallback(() => { setIsDescriptionExpanded((prev) => !prev); }, []); const handleDescriptionFullscreenKeyDown = useCallback((e: React.KeyboardEvent) => { if (!isDescriptionExpanded || e.key !== "Escape") return; e.preventDefault(); e.stopPropagation(); setIsDescriptionExpanded(false); }, [isDescriptionExpanded]); // 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, projectId); 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) { const errorMessage = getRefineErrorMessage(err); addToast(errorMessage, "error"); } finally { setIsRefining(false); } }, [description, isRefining, addToast, onDescriptionChange, projectId]); 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. const workflowStepLookup = new Map(); for (const step of workflowSteps) { workflowStepLookup.set(step.id, { name: step.name, description: step.description }); } 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 */}
{isDescriptionExpanded && (
Editing Description
)}