import "./QuickEntryBox.css"; import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { createPortal } from "react-dom"; import type { ToastType } from "../hooks/useToast"; import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, getErrorMessage } from "@fusion/core"; import type { Task, Settings, TaskPriority, ResolvedWorkflowOptionalStep, ThinkingLevel } from "@fusion/core"; import type { ModelInfo, Agent, CreateTaskInput, DuplicateMatch, BoardWorkflowDefinition, NodeInfo } from "../api"; import { checkDuplicateTasks, fetchModels, fetchSettings, updateGlobalSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../api"; import { DuplicateWarningModal } from "./DuplicateWarningModal"; import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server, Zap, Eye, EyeOff } from "lucide-react"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { LoadingSpinner } from "./LoadingSpinner"; import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage"; import { useNodes } from "../hooks/useNodes"; import { NodeHealthDot } from "./NodeHealthDot"; import { ProviderIcon } from "./ProviderIcon"; import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown"; import { WorkflowIcon } from "./WorkflowIcon"; import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils/priorityIndicator"; const STORAGE_KEY = "kb-quick-entry-text"; const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; interface PendingImage { file: File; previewUrl: string; } interface QuickEntryBoxProps { onCreate?: (input: CreateTaskInput) => Promise; addToast: (message: string, type?: ToastType) => void; tasks?: Task[]; availableModels?: ModelInfo[]; /** * Preserved for callers that still pass planning handoff props through shared quick-create plumbing. * QuickEntryBox intentionally does not render a quick-add Plan button. */ onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void; /** * Called when the user clicks the "Subtask" button to trigger subtask breakdown. */ onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void; /** Selected workflow lane for AI-assisted create actions. Omit in legacy board mode to preserve project-default inheritance. */ workflowId?: string | null; /** Real workflows available to the quick-add workflow selector. Board-only aggregate sentinels must be filtered before rendering/submission. */ workflowOptions?: BoardWorkflowDefinition[]; /** Project/default workflow id used when the parent view is aggregate or stale. */ defaultWorkflowId?: string | null; /** Optional project context for API calls */ projectId?: string; /** * When true, the component automatically expands when focused. * Set to false to keep the view collapsed until manually toggled. * Defaults to true for backward compatibility. */ autoExpand?: boolean; /* FNXC:QuickEntry 2026-06-22-01:10: Initial disclosure (expanded controls) state. List view passes false so quick-add starts COLLAPSED; Board/columns keep the default true so quick-add stays OPEN. This is independent of autoExpand (which only governs expand-on-focus). */ defaultExpanded?: boolean; /* FNXC:QuickEntry 2026-06-22-19:25: List view renders quick-add as a COMPACT single-line input so the box isn't tall. When true, the textarea stays one line: isExpanded initializes false, focus does NOT auto-expand it, and auto-resize-to-scrollHeight is short-circuited (capped to the one-line min-height). Board/columns omit singleLine, preserving the tall 80px + auto-grow behavior. singleLine governs only textarea height, not the disclosure/controls panel (which List already collapses via defaultExpanded={false}). */ singleLine?: boolean; /** * Favorited provider IDs from shared app-level state. * When provided (alongside availableModels), the component uses these * instead of its own internal favorite state. */ favoriteProviders?: string[]; /** * Favorited model IDs from shared app-level state. * When provided (alongside availableModels), the component uses these * instead of its own internal favorite state. */ favoriteModels?: string[]; /** * Toggle favorite provider callback from shared app-level state. */ onToggleFavorite?: (provider: string) => void; /** * Toggle favorite model callback from shared app-level state. */ onToggleModelFavorite?: (modelId: string) => void; onOpenTask?: (id: string) => void; } function getModelSelectionValue(provider?: string, modelId?: string): string { return provider && modelId ? `${provider}/${modelId}` : ""; } function parseModelSelection(value: string): { provider?: string; modelId?: string } { if (!value) { return { provider: undefined, modelId: undefined }; } const slashIndex = value.indexOf("/"); if (slashIndex === -1) { return { provider: undefined, modelId: undefined }; } return { provider: value.slice(0, slashIndex), modelId: value.slice(slashIndex + 1), }; } function getRealWorkflowOptions(workflowOptions: BoardWorkflowDefinition[] | undefined): BoardWorkflowDefinition[] { return (workflowOptions ?? []).filter((workflow) => workflow.id !== "__all_workflows__"); } function resolveQuickAddWorkflowId( parentWorkflowId: string | null | undefined, defaultWorkflowId: string | null | undefined, workflowOptions: BoardWorkflowDefinition[], ): string | null | undefined { if (parentWorkflowId === undefined) return undefined; if (parentWorkflowId === null) return null; if (workflowOptions.length === 0) return parentWorkflowId === "__all_workflows__" ? null : parentWorkflowId; const validIds = new Set(workflowOptions.map((workflow) => workflow.id)); if (validIds.has(parentWorkflowId)) return parentWorkflowId; if (defaultWorkflowId && validIds.has(defaultWorkflowId)) return defaultWorkflowId; return workflowOptions[0]?.id ?? null; } function hasMeaningfulNodeChoice(nodes: NodeInfo[]): boolean { /* FNXC:QuickAddNodeRouting 2026-06-30-00:00: Local-only projects should not show a Node button because the project-default route already means local execution. Keep the picker only when a remote or second registered node makes routing a real choice. */ return nodes.length > 1 || nodes.some((node) => node.type !== "local"); } export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onSubtaskBreakdown, workflowId, workflowOptions, defaultWorkflowId, projectId, autoExpand = true, defaultExpanded = true, singleLine = false, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) { const { t } = useTranslation("app"); const [description, setDescription] = useState(() => { if (typeof window !== "undefined") { return getScopedItem(STORAGE_KEY, projectId) || ""; } return ""; }); const [isSubmitting, setIsSubmitting] = useState(false); // isExpanded controls textarea height styling (auto-resize) // FNXC:QuickEntry 2026-06-22-19:25: singleLine (List view) starts collapsed so the textarea is one line, not the tall 80px variant. const [isExpanded, setIsExpanded] = useState(!singleLine); // isDisclosureExpanded controls visibility of the controls panel (Deps, Models, etc.) // Starts expanded by default — controls visible immediately const [isDisclosureExpanded, setIsDisclosureExpanded] = useState(defaultExpanded); const textareaRef = useRef(null); const fileInputRef = useRef(null); const touchButtonRef = useRef(null); const justResetRef = useRef(false); const previousProjectIdRef = useRef(projectId); const [pendingImages, setPendingImages] = useState([]); const pendingImagesRef = useRef([]); const [isFileDragOver, setIsFileDragOver] = useState(false); const dragDepthRef = useRef(0); // Rich creation state (mirrors InlineCreateCard) const [dependencies, setDependencies] = useState([]); const [showDeps, setShowDeps] = useState(false); const [depSearch, setDepSearch] = useState(""); const [selectedAgentId, setSelectedAgentId] = useState(null); const [agents, setAgents] = useState([]); const [agentsProjectId, setAgentsProjectId] = useState(undefined); const [showAgentPicker, setShowAgentPicker] = useState(false); const [showNodePicker, setShowNodePicker] = useState(false); const [showPriorityPicker, setShowPriorityPicker] = useState(false); const [agentsLoading, setAgentsLoading] = useState(false); const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); const [activeModelSubmenu, setActiveModelSubmenu] = useState<"plan" | "executor" | "validator" | null>(null); const [executorProvider, setExecutorProvider] = useState(undefined); const [executorModelId, setExecutorModelId] = useState(undefined); const [validatorProvider, setValidatorProvider] = useState(undefined); const [validatorModelId, setValidatorModelId] = useState(undefined); const [planningProvider, setPlanningProvider] = useState(undefined); const [planningModelId, setPlanningModelId] = useState(undefined); /* FNXC:Settings-ThinkingLevel 2026-07-09-00:00: inline quick-entry bar carries the same per-task thinking-level override as the full New Task modal; "" means "use default". */ const [thinkingLevel, setThinkingLevel] = useState(""); const modelTriggerRef = useRef(null); const modelMenuPortalRef = useRef(null); const agentPickerRef = useRef(null); const agentPickerPortalRef = useRef(null); const nodePickerRef = useRef(null); const nodePickerPortalRef = useRef(null); const priorityPickerRef = useRef(null); const priorityPickerPortalRef = useRef(null); const [agentPickerPosition, setAgentPickerPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null); const [nodePickerPosition, setNodePickerPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null); const [priorityPickerPosition, setPriorityPickerPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null); const [modelMenuPosition, setModelMenuPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null); // Dependency dropdown portal refs and state const depTriggerRef = useRef(null); const depDropdownPortalRef = useRef(null); const [depDropdownPosition, setDepDropdownPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null); const [portalRoot] = useState(() => typeof document !== "undefined" ? document.body : null, ); const realWorkflowOptions = useMemo(() => getRealWorkflowOptions(workflowOptions), [workflowOptions]); const workflowPickerRef = useRef(null); const workflowTriggerRef = useRef(null); const workflowPickerPortalRef = useRef(null); const [workflowPickerPosition, setWorkflowPickerPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null); const previousWorkflowDefaultRef = useRef<{ workflowId: string | null | undefined; defaultWorkflowId: string | null | undefined }>({ workflowId, defaultWorkflowId }); const [showWorkflowPicker, setShowWorkflowPicker] = useState(false); /* FNXC:QuickAddWorkflow 2026-06-30-00:00: Quick-add needs an independent real workflow target because the main Board selector can be on the aggregate "All workflows" read view. Resolve only against real workflow ids and keep the aggregate sentinel out of task create, Plan, Subtask, and optional-step requests. */ const [quickEntryWorkflowId, setQuickEntryWorkflowId] = useState(() => ( resolveQuickAddWorkflowId(workflowId, defaultWorkflowId, getRealWorkflowOptions(workflowOptions)) )); const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState(null); const [loadedModels, setLoadedModels] = useState(availableModels ?? []); const [favoriteProviders, setFavoriteProviders] = useState([]); const [favoriteModels, setFavoriteModels] = useState([]); const [settings, setSettings] = useState(null); const [selectedPresetId, setSelectedPresetId] = useState(undefined); const [optionalSteps, setOptionalSteps] = useState([]); const [enabledOptionalStepIds, setEnabledOptionalStepIds] = useState([]); const [isFastMode, setIsFastMode] = useState(false); const isFastModeRef = useRef(isFastMode); useEffect(() => { isFastModeRef.current = isFastMode; }, [isFastMode]); const [githubTrackingOverride, setGithubTrackingOverride] = useState(null); // FNXC:PlannerOversight 2026-07-14-18:11: null = follow project sessionAdvisorEnabledByDefault. const [sessionAdvisorOverride, setSessionAdvisorOverride] = useState(null); const [priority, setPriority] = useState(DEFAULT_TASK_PRIORITY); const [nodeId, setNodeId] = useState(undefined); const [duplicateMatches, setDuplicateMatches] = useState(null); const submitInFlightRef = useRef(false); const { nodes } = useNodes(); const shouldShowNodePicker = useMemo(() => hasMeaningfulNodeChoice(nodes), [nodes]); const selectedNode = shouldShowNodePicker && nodeId ? nodes.find((node) => node.id === nodeId) : undefined; const effectiveNodeId = shouldShowNodePicker && selectedNode ? selectedNode.id : undefined; useEffect(() => { if (shouldShowNodePicker && (!nodeId || selectedNode)) { return; } setNodeId(undefined); setShowNodePicker(false); setNodePickerPosition(null); }, [nodeId, selectedNode, shouldShowNodePicker]); // Use parent-provided favorites when available, otherwise internal state const effectiveFavoriteProviders = parentFavoriteProviders ?? favoriteProviders; const effectiveFavoriteModels = parentFavoriteModels ?? favoriteModels; // If onCreate is not provided, the component is disabled const isDisabled = !onCreate; // Fetch models if not provided by parent useEffect(() => { if (availableModels) { setLoadedModels(availableModels); setModelsLoading(false); setModelsError(null); return; } let cancelled = false; setModelsLoading(true); setModelsError(null); fetchModels() .then((response) => { if (!cancelled) { setLoadedModels(response.models); // Only set internal favorites when parent doesn't manage them if (!parentFavoriteProviders) { setFavoriteProviders(response.favoriteProviders); } if (!parentFavoriteModels) { setFavoriteModels(response.favoriteModels); } } }) .catch((err) => { if (!cancelled) { setModelsError(getErrorMessage(err) || t("tasks.loadModelsFailed", "Failed to load models")); } }) .finally(() => { if (!cancelled) { setModelsLoading(false); } }); return () => { cancelled = true; }; }, [availableModels, parentFavoriteProviders, parentFavoriteModels]); // Settings always drive toggle/preset behavior, regardless of model source. useEffect(() => { let cancelled = false; fetchSettings(projectId) .then((nextSettings) => { if (!cancelled) { setSettings(nextSettings); } }) .catch(() => { // Silently ignore settings fetch failure }); return () => { cancelled = true; }; }, [projectId]); /* FNXC:WorkflowOptionalSteps 2026-06-25-00:00: Quick-add creation needs the active workflow's optional steps in the immediate action row, seeded from each step's `defaultOn`, and forwarded through `enabledWorkflowSteps`. `null` workflow is an explicit no-workflow opt-out, while `undefined` inherits the project default so Board and List quick-add match TaskForm's create-time resolution. FNXC:WorkflowOptionalSteps 2026-06-26-05:10: Resolution MUST mirror the executor/store (explicit workflowId → project default → `builtin:coding`). The earlier `?? null` tail hid `builtin:coding`'s optional steps (browser-verification, code-review) whenever no project default workflow was configured, so operators never saw the toggles even though the unselected task runs `builtin:coding` (FN-7039). Fall back to `builtin:coding` once settings have loaded; stay `null` while settings are still loading so we don't fetch the wrong workflow then refetch. */ const selectedQuickEntryWorkflow = typeof quickEntryWorkflowId === "string" ? realWorkflowOptions.find((option) => option.id === quickEntryWorkflowId) : undefined; const quickEntryWorkflowNameCounts = useMemo(() => { const counts = new Map(); for (const option of realWorkflowOptions) { counts.set(option.name, (counts.get(option.name) ?? 0) + 1); } return counts; }, [realWorkflowOptions]); const showWorkflowSelector = workflowId !== undefined && realWorkflowOptions.length >= 2 && quickEntryWorkflowId !== null; const quickEntryWorkflowLabel = selectedQuickEntryWorkflow?.name ?? t("tasks.workflow", "Workflow"); const selectedQuickEntryWorkflowIcon = selectedQuickEntryWorkflow?.icon; const selectedWorkflowForCreate = workflowId === undefined ? undefined : quickEntryWorkflowId; useEffect(() => { const parentChanged = previousWorkflowDefaultRef.current.workflowId !== workflowId || previousWorkflowDefaultRef.current.defaultWorkflowId !== defaultWorkflowId; previousWorkflowDefaultRef.current = { workflowId, defaultWorkflowId }; setQuickEntryWorkflowId((current) => { const resolved = resolveQuickAddWorkflowId(workflowId, defaultWorkflowId, realWorkflowOptions); if (parentChanged) return resolved; if (typeof current === "string" && realWorkflowOptions.some((option) => option.id === current)) { return current; } return resolved; }); }, [defaultWorkflowId, realWorkflowOptions, workflowId]); const effectiveOptionalWorkflowId = selectedWorkflowForCreate === null ? null : (selectedWorkflowForCreate ?? settings?.defaultWorkflowId ?? (settings ? "builtin:coding" : null)); useEffect(() => { let cancelled = false; setOptionalSteps([]); setEnabledOptionalStepIds([]); if (!effectiveOptionalWorkflowId) { return () => { cancelled = true; }; } fetchWorkflowOptionalSteps(effectiveOptionalWorkflowId, projectId) .then((steps) => { if (cancelled) return; setOptionalSteps(steps); /* FNXC:FastOptionalSteps 2026-06-30-10:24: Optional-step metadata can resolve after the operator has already selected Fast. Seed `[]` in that race so async defaultOn loading cannot undo Fast's speed-first opt-out; later dropdown clicks still add explicit selections normally. */ setEnabledOptionalStepIds(isFastModeRef.current ? [] : steps.filter((step) => step.defaultOn).map((step) => step.templateId)); }) .catch(() => { if (cancelled) return; setOptionalSteps([]); setEnabledOptionalStepIds([]); }); return () => { cancelled = true; }; }, [effectiveOptionalWorkflowId, projectId]); const toggleOptionalStep = useCallback((templateId: string) => { setEnabledOptionalStepIds((prev) => ( prev.includes(templateId) ? prev.filter((id) => id !== templateId) : [...prev, templateId] )); }, []); /* FNXC:FastOptionalSteps 2026-06-30-09:05: Fast task creation is speed-first: switching standard → fast clears currently enabled optional workflow steps. The dropdown remains enabled so the operator can manually opt Browser Verification, Plan Review, Code Review, or a custom optional group back in before create. FNXC:FastOptionalSteps 2026-06-30-10:41: A Fast create must submit explicit `[]` even if optional-step metadata has not loaded yet; otherwise the store/engine see `enabledWorkflowSteps` as omitted and re-seed default-on gates. */ const toggleFastMode = useCallback(() => { setIsFastMode((prev) => { const next = !prev; if (next) setEnabledOptionalStepIds([]); return next; }); }, []); const executorSelectionValue = getModelSelectionValue(executorProvider, executorModelId); const validatorSelectionValue = getModelSelectionValue(validatorProvider, validatorModelId); const planningSelectionValue = getModelSelectionValue(planningProvider, planningModelId); const hasExecutorOverride = Boolean(executorProvider && executorModelId); const hasValidatorOverride = Boolean(validatorProvider && validatorModelId); const hasPlanningOverride = Boolean(planningProvider && planningModelId); const selectedModelCount = Number(hasExecutorOverride) + Number(hasValidatorOverride) + Number(hasPlanningOverride); const modelMenuLabel = selectedPresetId ? settings?.modelPresets?.find((p) => p.id === selectedPresetId)?.name ?? t("tasks.models", "Models") : selectedModelCount > 0 ? t("tasks.modelsCount", { count: selectedModelCount, defaultValue_one: "{{count}} model", defaultValue_other: "{{count}} models" }) : t("tasks.models", "Models"); const getModelBadgeLabel = useCallback( (provider?: string, modelId?: string) => { if (!provider || !modelId) return t("tasks.usingDefault", "Using default"); const matched = loadedModels.find((model) => model.provider === provider && model.id === modelId); return matched ? `${matched.provider}/${matched.id}` : `${provider}/${modelId}`; }, [loadedModels], ); useEffect(() => { setDescription(getScopedItem(STORAGE_KEY, projectId) || ""); }, [projectId]); // Persist description to localStorage whenever it changes useEffect(() => { if (typeof window !== "undefined") { setScopedItem(STORAGE_KEY, description, projectId); } }, [description, projectId]); // Clear agents cache when projectId changes to prevent stale agents from leaking across projects useEffect(() => { if (previousProjectIdRef.current === projectId) { return; } previousProjectIdRef.current = projectId; setAgents([]); setAgentsProjectId(undefined); setSelectedAgentId(null); setShowAgentPicker(false); setAgentPickerPosition(null); }, [projectId]); // Clean up legacy disclosure persistence key from previous versions useEffect(() => { if (typeof window !== "undefined") { localStorage.removeItem("kb-quick-entry-expanded"); } }, []); useEffect(() => { pendingImagesRef.current = pendingImages; }, [pendingImages]); // Cleanup image preview URLs on unmount useEffect(() => { return () => { pendingImagesRef.current.forEach((img) => URL.revokeObjectURL(img.previewUrl)); }; }, []); // Auto-resize textarea based on content const autoResize = useCallback(() => { const textarea = textareaRef.current; if (!textarea) return; // Reset height to auto to get accurate scrollHeight textarea.style.height = "auto"; // Set to scrollHeight (capped at max-height via CSS) const newHeight = Math.min(textarea.scrollHeight, 200); textarea.style.height = `${newHeight}px`; }, []); // Resize when description changes (not in fullscreen mode since CSS handles it) // FNXC:QuickEntry 2026-06-22-19:25: singleLine (List view) must stay one line — skip auto-resize-to-scrollHeight so the textarea never grows tall with content; CSS clamps it to the one-line height. useEffect(() => { if (isExpanded && !singleLine) { autoResize(); } }, [description, isExpanded, autoResize, singleLine]); /* FNXC:QuickEntryFocus 2026-06-25-00:00: After a successful task creation, the quick-entry textarea must not re-focus itself on any surface, desktop or mobile. The user explicitly does not want focus to return to the input after adding a task, superseding FN-6217/FN-6219; keep clearing the form on submit. */ // Clear dep search when dropdown closes useEffect(() => { if (!showDeps) setDepSearch(""); }, [showDeps]); // Close model menu when clicking outside useEffect(() => { if (!isModelMenuOpen) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node; const clickedInsideTrigger = modelTriggerRef.current?.contains(target); const clickedInsidePortal = modelMenuPortalRef.current?.contains(target); // Also check for clicks inside CustomModelDropdown's portaled dropdown const clickedInsideCombobox = (target instanceof Element) && (target.closest?.(".model-combobox-dropdown--portal") != null); if (!clickedInsideTrigger && !clickedInsidePortal && !clickedInsideCombobox) { setIsModelMenuOpen(false); setActiveModelSubmenu(null); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [isModelMenuOpen]); useEffect(() => { if (!showNodePicker) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node; if (nodePickerRef.current?.contains(target)) return; if (nodePickerPortalRef.current?.contains(target)) return; setShowNodePicker(false); setNodePickerPosition(null); }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [showNodePicker]); useEffect(() => { if (!showAgentPicker) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node; // Check both the trigger button and the portaled dropdown if (agentPickerRef.current?.contains(target)) return; if (agentPickerPortalRef.current?.contains(target)) return; setShowAgentPicker(false); setAgentPickerPosition(null); }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [showAgentPicker]); useEffect(() => { if (!showPriorityPicker) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node; if (priorityPickerRef.current?.contains(target)) return; if (priorityPickerPortalRef.current?.contains(target)) return; setShowPriorityPicker(false); setPriorityPickerPosition(null); }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [showPriorityPicker]); useEffect(() => { if (!showWorkflowPicker) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node; if (workflowPickerRef.current?.contains(target)) return; if (workflowPickerPortalRef.current?.contains(target)) return; setShowWorkflowPicker(false); setWorkflowPickerPosition(null); }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [showWorkflowPicker]); const resetForm = useCallback(() => { pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); setPendingImages([]); dragDepthRef.current = 0; setIsFileDragOver(false); if (fileInputRef.current) { fileInputRef.current.value = ""; } setDescription(""); setDependencies([]); setSelectedAgentId(null); setShowAgentPicker(false); setAgentPickerPosition(null); setShowNodePicker(false); setNodePickerPosition(null); setShowPriorityPicker(false); setPriorityPickerPosition(null); setExecutorProvider(undefined); setExecutorModelId(undefined); setValidatorProvider(undefined); setValidatorModelId(undefined); setPlanningProvider(undefined); setPlanningModelId(undefined); setThinkingLevel(""); setSelectedPresetId(undefined); setEnabledOptionalStepIds(optionalSteps.filter((step) => step.defaultOn).map((step) => step.templateId)); setIsFastMode(false); setGithubTrackingOverride(null); setSessionAdvisorOverride(null); setPriority(DEFAULT_TASK_PRIORITY); setNodeId(undefined); setShowDeps(false); setIsModelMenuOpen(false); setModelMenuPosition(null); setActiveModelSubmenu(null); justResetRef.current = true; if (textareaRef.current) { textareaRef.current.style.height = "auto"; } // Clear localStorage when form is reset (after successful creation) if (typeof window !== "undefined") { removeScopedItem(STORAGE_KEY, projectId); } }, [pendingImages, projectId, optionalSteps]); const handleImageFiles = useCallback((files: FileList | File[] | null | undefined) => { if (!files || files.length === 0) return; const newImages: PendingImage[] = []; for (let i = 0; i < files.length; i++) { const file = files[i]; if (ALLOWED_IMAGE_TYPES.includes(file.type)) { newImages.push({ file, previewUrl: URL.createObjectURL(file) }); } } if (newImages.length > 0) { setPendingImages((prev) => [...prev, ...newImages]); } }, []); const isFileDrag = useCallback((dataTransfer: DataTransfer | null) => { if (!dataTransfer) return false; return Array.from(dataTransfer.types ?? []).includes("Files"); }, []); /* FNXC:QuickAddAttachments 2026-06-30-00:00: Quick Add uses an icon-only paperclip to keep the action row compact, so the accessible name and title carry the Attach action plus pending-image count. The same image intake path handles file input, paste, and file drag/drop so previews and post-create uploads stay consistent. */ const attachLabel = pendingImages.length > 0 ? t("tasks.attachImagesCount", "Attach images ({{count}} pending)", { count: pendingImages.length }) : t("tasks.attachImages", "Attach images"); const handleDragEnter = useCallback((e: React.DragEvent) => { if (!isFileDrag(e.dataTransfer)) return; e.preventDefault(); dragDepthRef.current += 1; setIsFileDragOver(true); }, [isFileDrag]); const handleDragOver = useCallback((e: React.DragEvent) => { if (!isFileDrag(e.dataTransfer)) return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; setIsFileDragOver(true); }, [isFileDrag]); const clearFileDragState = useCallback(() => { dragDepthRef.current = 0; setIsFileDragOver(false); }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { if (!isFileDrag(e.dataTransfer)) return; e.preventDefault(); dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) { setIsFileDragOver(false); } }, [isFileDrag]); const handleDrop = useCallback((e: React.DragEvent) => { if (!isFileDrag(e.dataTransfer)) return; e.preventDefault(); clearFileDragState(); if (isSubmitting) return; handleImageFiles(e.dataTransfer.files); }, [clearFileDragState, handleImageFiles, isFileDrag, isSubmitting]); const handlePaste = useCallback((e: React.ClipboardEvent) => { if (isSubmitting) return; handleImageFiles(e.clipboardData?.files); }, [handleImageFiles, isSubmitting]); const removeImage = useCallback((index: number) => { setPendingImages((prev) => { const removed = prev[index]; if (removed) { URL.revokeObjectURL(removed.previewUrl); } return prev.filter((_, i) => i !== index); }); }, []); const submitCreateTask = useCallback(async (trimmed: string, overrides?: { acknowledgedDuplicates?: string[] }) => { if (!onCreate) { return; } const originalDescription = description; setDescription(""); try { /* FNXC:CodingIdeasWorkflow 2026-07-05-00:00: Do not hard-code `column: "triage"` — the store resolves the landing column from the forwarded (or project-default) workflow's intake column, so a manual-intake workflow (e.g. Coding (Ideas) → "ideas") parks the card for the operator instead of being auto-triaged. */ const createdTask = await onCreate({ description: trimmed, ...(selectedWorkflowForCreate !== undefined ? { workflowId: selectedWorkflowForCreate } : {}), dependencies: dependencies.length ? dependencies : undefined, ...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}), modelPresetId: selectedPresetId, modelProvider: hasExecutorOverride ? executorProvider : undefined, modelId: hasExecutorOverride ? executorModelId : undefined, validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined, validatorModelId: hasValidatorOverride ? validatorModelId : undefined, planningModelProvider: hasPlanningOverride ? planningProvider : undefined, planningModelId: hasPlanningOverride ? planningModelId : undefined, thinkingLevel: thinkingLevel !== "" ? (thinkingLevel as ThinkingLevel) : undefined, /* FNXC:QuickAddWorkflowSteps 2026-06-29-01:31: Quick Add optional-step toggles are explicit task intent. When the workflow exposes optional steps and the user unchecks every one, submit an empty array instead of omitting the field so default-on Plan Review / Code Review do not reappear on the created task. */ enabledWorkflowSteps: isFastMode || optionalSteps.length > 0 ? enabledOptionalStepIds : undefined, ...(isFastMode ? { executionMode: "fast" } : {}), githubTracking: githubTrackingOverride !== null ? { enabled: githubTrackingOverride } : undefined, // FNXC:PlannerOversight 2026-07-14-18:11: only send when user toggled away from project default. sessionAdvisorEnabled: sessionAdvisorOverride !== null ? sessionAdvisorOverride : undefined, priority, nodeId: effectiveNodeId, acknowledgedDuplicates: overrides?.acknowledgedDuplicates, }); if (createdTask && pendingImages.length > 0) { const failures: string[] = []; for (const pendingImage of pendingImages) { try { await uploadAttachment(createdTask.id, pendingImage.file, projectId); } catch { failures.push(pendingImage.file.name); } } if (failures.length > 0) { addToast(t("tasks.uploadFailed", "Failed to upload: {{files}}", { files: failures.join(", ") }), "error"); } } resetForm(); } catch (err) { setDescription(originalDescription); addToast(getErrorMessage(err) || t("tasks.createFailed", "Failed to create task"), "error"); } finally { submitInFlightRef.current = false; setIsSubmitting(false); } }, [ onCreate, description, dependencies, selectedWorkflowForCreate, selectedAgentId, selectedPresetId, hasExecutorOverride, executorProvider, executorModelId, hasValidatorOverride, validatorProvider, validatorModelId, hasPlanningOverride, planningProvider, planningModelId, thinkingLevel, enabledOptionalStepIds, isFastMode, settings, githubTrackingOverride, sessionAdvisorOverride, priority, effectiveNodeId, pendingImages, projectId, addToast, resetForm, ]); const handleSubmit = useCallback(async () => { if (submitInFlightRef.current) { return; } submitInFlightRef.current = true; setIsSubmitting(true); const trimmed = description.trim(); if (!trimmed || !onCreate) { submitInFlightRef.current = false; setIsSubmitting(false); return; } let releaseLockOnExit = true; try { const matches = await checkDuplicateTasks({ description: trimmed }, projectId); if (matches.length > 0) { setDuplicateMatches(matches); releaseLockOnExit = false; return; } } catch (_error) { addToast(t("tasks.duplicateCheckFailed", "Duplicate check failed; creating task anyway."), "error"); } releaseLockOnExit = false; await submitCreateTask(trimmed); if (releaseLockOnExit) { submitInFlightRef.current = false; setIsSubmitting(false); } }, [description, onCreate, projectId, submitCreateTask, addToast]); const handleDuplicateOpen = useCallback((taskId: string) => { if (onOpenTask) { onOpenTask(taskId); } else if (typeof window !== "undefined") { window.location.hash = `#/tasks/${taskId}`; } setDuplicateMatches(null); }, [onOpenTask]); const handleDuplicateProceed = useCallback(async () => { const trimmed = description.trim(); const matches = duplicateMatches; if (!trimmed || !matches || matches.length === 0) { setDuplicateMatches(null); submitInFlightRef.current = false; setIsSubmitting(false); return; } setDuplicateMatches(null); // Keep optimistic submit lock active while duplicate-confirmed creation is in flight. submitInFlightRef.current = true; setIsSubmitting(true); await submitCreateTask(trimmed, { acknowledgedDuplicates: matches.map((match) => match.id) }); }, [description, duplicateMatches, submitCreateTask]); const handleDuplicateCancel = useCallback(() => { setDuplicateMatches(null); submitInFlightRef.current = false; setIsSubmitting(false); }, []); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter") { if (e.shiftKey) { // Allow Shift+Enter to insert a newline in any quick-entry state // Don't prevent default - let the newline be inserted // FNXC:QuickEntry 2026-06-22-19:25: singleLine (List view) stays one line even on Shift+Enter — do not expand the textarea. if (!singleLine) { setIsExpanded(true); } return; } // Enter without Shift submits e.preventDefault(); if (duplicateMatches || submitInFlightRef.current) { return; } handleSubmit(); } else if (e.key === "Escape") { e.preventDefault(); // Close model submenu first if open if (activeModelSubmenu) { setActiveModelSubmenu(null); return; } // Close model menu if open if (isModelMenuOpen) { setIsModelMenuOpen(false); setModelMenuPosition(null); return; } if (showDeps) { setShowDeps(false); return; } if (showNodePicker) { setShowNodePicker(false); setNodePickerPosition(null); return; } if (showPriorityPicker) { setShowPriorityPicker(false); setPriorityPickerPosition(null); return; } if (showWorkflowPicker) { setShowWorkflowPicker(false); setWorkflowPickerPosition(null); return; } if (showAgentPicker) { setShowAgentPicker(false); setAgentPickerPosition(null); return; } // Clear non-empty input on Escape and clear localStorage if (description.trim()) { setDescription(""); // Reset height if (textareaRef.current) { textareaRef.current.style.height = "auto"; } // Clear localStorage when user explicitly clears input if (typeof window !== "undefined") { removeScopedItem(STORAGE_KEY, projectId); } } // Collapse textarea and disclosure on escape setIsExpanded(false); setIsDisclosureExpanded(false); textareaRef.current?.blur(); } }, [ handleSubmit, description, isExpanded, showDeps, showAgentPicker, showNodePicker, isModelMenuOpen, activeModelSubmenu, showPriorityPicker, showWorkflowPicker, projectId, setIsDisclosureExpanded, duplicateMatches, singleLine, ], ); const handleBlur = useCallback(() => { // No auto-collapse on blur — state persists until manually toggled or task is submitted/cancelled // Only clear the justResetRef flag if needed if (justResetRef.current) { justResetRef.current = false; } }, []); const handleFocus = useCallback(() => { // Auto-expand on focus when autoExpand prop is true (default) // FNXC:QuickEntry 2026-06-22-19:25: never auto-expand the textarea on focus when singleLine (List view) — it must stay one line. if (autoExpand && !singleLine) { setIsExpanded(true); } }, [autoExpand, singleLine]); const toggleDep = useCallback((id: string) => { setDependencies((prev) => prev.includes(id) ? prev.filter((d) => d !== id) : [...prev, id], ); }, []); const getEffectiveViewport = useCallback(() => { const vv = window.visualViewport; if (vv && vv.width > 0 && vv.height > 0) { return { width: vv.width, height: vv.height, offsetTop: vv.offsetTop, offsetLeft: vv.offsetLeft, }; } return { width: window.innerWidth, height: window.innerHeight, offsetTop: 0, offsetLeft: 0, }; }, []); const updateModelMenuPosition = useCallback(() => { const trigger = modelTriggerRef.current; if (!trigger) return; const rect = trigger.getBoundingClientRect(); const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport(); const horizontalPadding = 16; const verticalPadding = 16; const gap = 4; const isMobile = viewportWidth <= 768; const preferredHeight = isMobile ? Math.min(viewportHeight * 0.6, 360) : Math.min(viewportHeight * 0.5, 360); const preferredDesktopWidth = Math.max(rect.width * 1.35, 320); const preferredWidth = isMobile ? Math.min(viewportWidth - horizontalPadding * 2, 360) : preferredDesktopWidth; const width = Math.min( preferredWidth, Math.max(viewportWidth - horizontalPadding * 2, 240), ); const triggerTop = rect.top - offsetTop; const triggerBottom = rect.bottom - offsetTop; const triggerLeft = rect.left - offsetLeft; const spaceBelow = viewportHeight - triggerBottom; const spaceAbove = triggerTop; const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 160); const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 160); const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow; const maxHeight = Math.max( Math.min(openUpward ? availableAbove : availableBelow, preferredHeight), 160, ); const left = Math.min( Math.max(triggerLeft, horizontalPadding), viewportWidth - horizontalPadding - width, ) + offsetLeft; const top = openUpward ? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop) : Math.min( triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalPadding - maxHeight, ); setModelMenuPosition({ top, left, width, maxHeight, }); }, [getEffectiveViewport]); const updateWorkflowPickerPosition = useCallback(() => { const trigger = workflowTriggerRef.current; if (!trigger) return; const rect = trigger.getBoundingClientRect(); const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport(); const horizontalPadding = 16; const verticalPadding = 16; const gap = 4; const isMobile = viewportWidth <= 768; const preferredHeight = Math.min(viewportHeight * (isMobile ? 0.6 : 0.5), 360); /* FNXC:QuickAddWorkflow 2026-06-30-16:16: The workflow menu is wider than its compact trigger, so portal and clamp it against the visual viewport instead of anchoring it to the trigger's inline start. This keeps right-side Board columns and wrapped mobile action rows readable without horizontal overflow. */ const preferredWidth = isMobile ? Math.min(viewportWidth - horizontalPadding * 2, 448) : Math.min(Math.max(rect.width * 3, 448), 512); const width = Math.min( preferredWidth, Math.max(viewportWidth - horizontalPadding * 2, 240), ); const triggerTop = rect.top - offsetTop; const triggerBottom = rect.bottom - offsetTop; const triggerLeft = rect.left - offsetLeft; const spaceBelow = viewportHeight - triggerBottom; const spaceAbove = triggerTop; const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 180); const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 180); const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow; const maxHeight = Math.max( Math.min(openUpward ? availableAbove : availableBelow, preferredHeight), 180, ); const left = Math.min( Math.max(triggerLeft, horizontalPadding), viewportWidth - horizontalPadding - width, ) + offsetLeft; const top = openUpward ? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop) : Math.min( triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalPadding - maxHeight, ); setWorkflowPickerPosition({ top, left, width, maxHeight }); }, [getEffectiveViewport]); const updateDepDropdownPosition = useCallback(() => { const trigger = depTriggerRef.current; if (!trigger) return; const rect = trigger.getBoundingClientRect(); const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport(); const horizontalPadding = 16; const verticalPadding = 16; const gap = 4; const isMobile = viewportWidth <= 768; const preferredHeight = isMobile ? Math.min(viewportHeight * 0.6, 320) : Math.min(viewportHeight * 0.5, 320); // Wider dropdown for dependency selection - easier to read task names const preferredWidth = isMobile ? Math.min(viewportWidth - horizontalPadding * 2, 360) : Math.max(rect.width, 280); const width = Math.min( preferredWidth, Math.max(viewportWidth - horizontalPadding * 2, 240), ); const triggerTop = rect.top - offsetTop; const triggerBottom = rect.bottom - offsetTop; const triggerLeft = rect.left - offsetLeft; const spaceBelow = viewportHeight - triggerBottom; const spaceAbove = triggerTop; const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 200); const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 200); const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow; const maxHeight = Math.max( Math.min(openUpward ? availableAbove : availableBelow, preferredHeight), 200, ); const left = Math.min( Math.max(triggerLeft, horizontalPadding), viewportWidth - horizontalPadding - width, ) + offsetLeft; const top = openUpward ? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop) : Math.min( triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalPadding - maxHeight, ); setDepDropdownPosition({ top, left, width, maxHeight, }); }, [getEffectiveViewport]); const updateAgentPickerPosition = useCallback(() => { const trigger = agentPickerRef.current?.querySelector("button") as HTMLButtonElement | null; if (!trigger) return; const rect = trigger.getBoundingClientRect(); const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport(); const horizontalPadding = 16; const verticalPadding = 16; const gap = 4; const isMobile = viewportWidth <= 768; const preferredHeight = isMobile ? Math.min(viewportHeight * 0.6, 320) : Math.min(viewportHeight * 0.5, 320); const preferredWidth = isMobile ? Math.min(viewportWidth - horizontalPadding * 2, 280) : Math.max(rect.width, 240); const width = Math.min( preferredWidth, Math.max(viewportWidth - horizontalPadding * 2, 200), ); const triggerTop = rect.top - offsetTop; const triggerBottom = rect.bottom - offsetTop; const triggerLeft = rect.left - offsetLeft; const spaceBelow = viewportHeight - triggerBottom; const spaceAbove = triggerTop; const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 160); const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 160); const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow; const maxHeight = Math.max( Math.min(openUpward ? availableAbove : availableBelow, preferredHeight), 160, ); const left = Math.min( Math.max(triggerLeft, horizontalPadding), viewportWidth - horizontalPadding - width, ) + offsetLeft; const top = openUpward ? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop) : Math.min( triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalPadding - maxHeight, ); setAgentPickerPosition({ top, left, width, maxHeight, }); }, [getEffectiveViewport]); const updateNodePickerPosition = useCallback(() => { const trigger = nodePickerRef.current?.querySelector("button") as HTMLButtonElement | null; if (!trigger) return; const rect = trigger.getBoundingClientRect(); const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport(); const horizontalPadding = 16; const verticalPadding = 16; const gap = 4; const isMobile = viewportWidth <= 768; const preferredHeight = isMobile ? Math.min(viewportHeight * 0.6, 320) : Math.min(viewportHeight * 0.5, 320); const preferredWidth = isMobile ? Math.min(viewportWidth - horizontalPadding * 2, 280) : Math.max(rect.width, 240); const width = Math.min( preferredWidth, Math.max(viewportWidth - horizontalPadding * 2, 200), ); const triggerTop = rect.top - offsetTop; const triggerBottom = rect.bottom - offsetTop; const triggerLeft = rect.left - offsetLeft; const spaceBelow = viewportHeight - triggerBottom; const spaceAbove = triggerTop; const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 160); const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 160); const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow; const maxHeight = Math.max( Math.min(openUpward ? availableAbove : availableBelow, preferredHeight), 160, ); const left = Math.min( Math.max(triggerLeft, horizontalPadding), viewportWidth - horizontalPadding - width, ) + offsetLeft; const top = openUpward ? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop) : Math.min( triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalPadding - maxHeight, ); setNodePickerPosition({ top, left, width, maxHeight, }); }, [getEffectiveViewport]); const updatePriorityPickerPosition = useCallback(() => { const trigger = priorityPickerRef.current?.querySelector("button") as HTMLButtonElement | null; if (!trigger) return; const rect = trigger.getBoundingClientRect(); const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport(); const horizontalPadding = 16; const verticalPadding = 16; const gap = 4; const preferredHeight = 220; const width = Math.min( Math.max(rect.width, 200), Math.max(viewportWidth - horizontalPadding * 2, 200), ); const triggerTop = rect.top - offsetTop; const triggerBottom = rect.bottom - offsetTop; const triggerLeft = rect.left - offsetLeft; const spaceBelow = viewportHeight - triggerBottom; const spaceAbove = triggerTop; const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 160); const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 160); const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow; const maxHeight = Math.max( Math.min(openUpward ? availableAbove : availableBelow, preferredHeight), 160, ); const left = Math.min( Math.max(triggerLeft, horizontalPadding), viewportWidth - horizontalPadding - width, ) + offsetLeft; const top = openUpward ? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop) : Math.min( triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalPadding - maxHeight, ); setPriorityPickerPosition({ top, left, width, maxHeight, }); }, [getEffectiveViewport]); // Keep model menu portal anchored during scroll/resize useEffect(() => { if (!isModelMenuOpen) return; const handleReposition = () => updateModelMenuPosition(); window.addEventListener("resize", handleReposition); window.addEventListener("scroll", handleReposition, true); const vv = window.visualViewport; if (vv) { vv.addEventListener("resize", handleReposition); vv.addEventListener("scroll", handleReposition); } return () => { window.removeEventListener("resize", handleReposition); window.removeEventListener("scroll", handleReposition, true); if (vv) { vv.removeEventListener("resize", handleReposition); vv.removeEventListener("scroll", handleReposition); } }; }, [isModelMenuOpen, updateModelMenuPosition]); // Keep workflow picker portal anchored during scroll/resize useEffect(() => { if (!showWorkflowPicker) return; const handleReposition = () => updateWorkflowPickerPosition(); window.addEventListener("resize", handleReposition); window.addEventListener("scroll", handleReposition, true); const vv = window.visualViewport; if (vv) { vv.addEventListener("resize", handleReposition); vv.addEventListener("scroll", handleReposition); } return () => { window.removeEventListener("resize", handleReposition); window.removeEventListener("scroll", handleReposition, true); if (vv) { vv.removeEventListener("resize", handleReposition); vv.removeEventListener("scroll", handleReposition); } }; }, [showWorkflowPicker, updateWorkflowPickerPosition]); // Keep dependency dropdown portal anchored during scroll/resize useEffect(() => { if (!showDeps) return; const handleReposition = () => updateDepDropdownPosition(); window.addEventListener("resize", handleReposition); window.addEventListener("scroll", handleReposition, true); const vv = window.visualViewport; if (vv) { vv.addEventListener("resize", handleReposition); vv.addEventListener("scroll", handleReposition); } return () => { window.removeEventListener("resize", handleReposition); window.removeEventListener("scroll", handleReposition, true); if (vv) { vv.removeEventListener("resize", handleReposition); vv.removeEventListener("scroll", handleReposition); } }; }, [showDeps, updateDepDropdownPosition]); // Keep agent picker portal anchored during scroll/resize useEffect(() => { if (!showAgentPicker) return; const handleReposition = () => updateAgentPickerPosition(); window.addEventListener("resize", handleReposition); window.addEventListener("scroll", handleReposition, true); const vv = window.visualViewport; if (vv) { vv.addEventListener("resize", handleReposition); vv.addEventListener("scroll", handleReposition); } return () => { window.removeEventListener("resize", handleReposition); window.removeEventListener("scroll", handleReposition, true); if (vv) { vv.removeEventListener("resize", handleReposition); vv.removeEventListener("scroll", handleReposition); } }; }, [showAgentPicker, updateAgentPickerPosition]); // Keep node picker portal anchored during scroll/resize useEffect(() => { if (!showNodePicker) return; const handleReposition = () => updateNodePickerPosition(); window.addEventListener("resize", handleReposition); window.addEventListener("scroll", handleReposition, true); const vv = window.visualViewport; if (vv) { vv.addEventListener("resize", handleReposition); vv.addEventListener("scroll", handleReposition); } return () => { window.removeEventListener("resize", handleReposition); window.removeEventListener("scroll", handleReposition, true); if (vv) { vv.removeEventListener("resize", handleReposition); vv.removeEventListener("scroll", handleReposition); } }; }, [showNodePicker, updateNodePickerPosition]); // Keep priority picker portal anchored during scroll/resize useEffect(() => { if (!showPriorityPicker) return; const handleReposition = () => updatePriorityPickerPosition(); window.addEventListener("resize", handleReposition); window.addEventListener("scroll", handleReposition, true); const vv = window.visualViewport; if (vv) { vv.addEventListener("resize", handleReposition); vv.addEventListener("scroll", handleReposition); } return () => { window.removeEventListener("resize", handleReposition); window.removeEventListener("scroll", handleReposition, true); if (vv) { vv.removeEventListener("resize", handleReposition); vv.removeEventListener("scroll", handleReposition); } }; }, [showPriorityPicker, updatePriorityPickerPosition]); const handlePlanningModelChange = useCallback((value: string) => { const next = parseModelSelection(value); setPlanningProvider(next.provider); setPlanningModelId(next.modelId); }, []); const handleExecutorChange = useCallback((value: string) => { const next = parseModelSelection(value); setExecutorProvider(next.provider); setExecutorModelId(next.modelId); }, []); const handleValidatorChange = useCallback((value: string) => { const next = parseModelSelection(value); setValidatorProvider(next.provider); setValidatorModelId(next.modelId); }, []); const handleThinkingLevelChange = useCallback((value: string) => { setThinkingLevel(value); }, []); const handleToggleFavorite = useCallback(async (provider: string) => { // Delegate to parent callback when available if (parentToggleFavorite) { parentToggleFavorite(provider); return; } 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 { // Revert on error setFavoriteProviders(currentFavorites); } }, [favoriteProviders, favoriteModels, parentToggleFavorite]); const handleToggleModelFavorite = useCallback(async (modelId: string) => { // Delegate to parent callback when available if (parentToggleModelFavorite) { parentToggleModelFavorite(modelId); return; } 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 { // Revert on error setFavoriteModels(currentFavorites); } }, [favoriteModels, favoriteProviders, parentToggleModelFavorite]); /* FNXC:QuickEntry 2026-06-30-00:00: Quick-add intentionally exposes no Plan button, disabled Plan state, tooltip, test id, or click target. Keep non-quick-add planning entry points such as the New Task dialog and model-menu planning lane intact. */ const handleSubtaskClick = useCallback(() => { const trimmed = description.trim(); if (!trimmed) { addToast(t("tasks.enterDescriptionFirst", "Enter a description first"), "error"); return; } if (selectedWorkflowForCreate !== undefined) { onSubtaskBreakdown?.(trimmed, selectedWorkflowForCreate); } else { onSubtaskBreakdown?.(trimmed); } // Clear the form after triggering subtask breakdown resetForm(); }, [description, onSubtaskBreakdown, selectedWorkflowForCreate, addToast, resetForm]); const handleSaveClick = useCallback(() => { // Save button now creates the task (same as Enter key) handleSubmit(); }, [handleSubmit]); const truncate = (s: string, len: number) => s.length > len ? s.slice(0, len) + "…" : s; const loadModels = useCallback(async () => { if (availableModels) { setLoadedModels(availableModels); setModelsError(null); setModelsLoading(false); return; } setModelsLoading(true); setModelsError(null); try { const response = await fetchModels(); setLoadedModels(response.models); // Only set internal favorites when parent doesn't manage them if (!parentFavoriteProviders) { setFavoriteProviders(response.favoriteProviders); } if (!parentFavoriteModels) { setFavoriteModels(response.favoriteModels); } } catch (err) { setModelsError(getErrorMessage(err) || t("tasks.loadModelsFailed", "Failed to load models")); } finally { setModelsLoading(false); } }, [availableModels, parentFavoriteProviders, parentFavoriteModels]); const loadAgents = useCallback(async () => { if (agents.length > 0 && agentsProjectId === projectId) { setShowAgentPicker(true); updateAgentPickerPosition(); return; } setAgentsLoading(true); try { const result = await fetchAgents(undefined, projectId); setAgents(result); setAgentsProjectId(projectId); setShowAgentPicker(true); updateAgentPickerPosition(); } catch (err) { const msg = getErrorMessage(err); addToast(msg ? t("tasks.loadAgentsFailed", "Failed to load agents: {{msg}}", { msg }) : t("tasks.loadAgentsFailedGeneric", "Failed to load agents"), "error"); setShowAgentPicker(false); } finally { setAgentsLoading(false); } }, [agents.length, agentsProjectId, projectId, addToast, updateAgentPickerPosition]); const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined; const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId; const projectGithubTrackingDefault = settings?.githubTrackingEnabledByDefault === true; const effectiveGithubTracking = githubTrackingOverride ?? projectGithubTrackingDefault; const githubToggleLabel = effectiveGithubTracking ? t("tasks.githubTrackingOn", "GitHub tracking ON for next task (project default: {{default}})", { default: projectGithubTrackingDefault ? t("tasks.githubTrackingDefaultOn", "on") : t("tasks.githubTrackingDefaultOff", "off") }) : t("tasks.githubTrackingOff", "GitHub tracking OFF for next task (project default: {{default}})", { default: projectGithubTrackingDefault ? t("tasks.githubTrackingDefaultOn", "on") : t("tasks.githubTrackingDefaultOff", "off") }); /* FNXC:PlannerOversight 2026-07-14-18:11: Quick Add eye toggle for session advisor. null override follows project default; toggle flips effective on/off and stores an explicit override for the next create. */ const projectSessionAdvisorDefault = settings?.sessionAdvisorEnabledByDefault === true; const effectiveSessionAdvisor = sessionAdvisorOverride ?? projectSessionAdvisorDefault; const sessionAdvisorToggleLabel = effectiveSessionAdvisor ? t("tasks.sessionAdvisorOn", "Session advisor ON for next task (project default: {{default}})", { default: projectSessionAdvisorDefault ? t("tasks.sessionAdvisorDefaultOn", "on") : t("tasks.sessionAdvisorDefaultOff", "off") }) : t("tasks.sessionAdvisorOff", "Session advisor OFF for next task (project default: {{default}})", { default: projectSessionAdvisorDefault ? t("tasks.sessionAdvisorDefaultOn", "on") : t("tasks.sessionAdvisorDefaultOff", "off") }); const PriorityIcon = getPriorityIcon(priority); const priorityLabel = getPriorityLabel(priority); const priorityButtonLabel = t("tasks.quickEntryPriorityLabel", "Priority: {{priority}}", { priority: priorityLabel }); const fastToggleLabel = t("tasks.toggleFastMode", "Toggle fast execution mode"); // Show expanded controls based on disclosure state (user preference), not textarea focus const showExpandedControls = isDisclosureExpanded; const toggleExpanded = useCallback(() => { setIsDisclosureExpanded((prev) => { const next = !prev; setIsExpanded(next); return next; }); }, []); return ( <>
{isFileDragOver && ( )}