import { useState, useCallback, useRef, useEffect } from "react"; import type { ToastType } from "../hooks/useToast"; import type { Task, TaskCreateInput } from "@fusion/core"; import type { ModelInfo, RefinementType } from "../api"; import { fetchModels, refineText, getRefineErrorMessage } from "../api"; import { Link, Brain, Lightbulb, ListTree, Sparkles, Save } from "lucide-react"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { ModelSelectionModal } from "./ModelSelectionModal"; const STORAGE_KEY = "kb-quick-entry-text"; interface QuickEntryBoxProps { onCreate?: (input: TaskCreateInput) => Promise; addToast: (message: string, type?: ToastType) => void; tasks?: Task[]; availableModels?: ModelInfo[]; /** * Called when the user clicks the "Plan" button to open planning mode. */ onPlanningMode?: (initialPlan: string) => void; /** * Called when the user clicks the "Subtask" button to trigger subtask breakdown. */ onSubtaskBreakdown?: (description: 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), }; } export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown }: QuickEntryBoxProps) { const [description, setDescription] = useState(() => { if (typeof window !== "undefined") { return localStorage.getItem(STORAGE_KEY) || ""; } return ""; }); const [isSubmitting, setIsSubmitting] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const textareaRef = useRef(null); const blurTimeoutRef = useRef(null); const justResetRef = useRef(false); // Rich creation state (mirrors InlineCreateCard) const [dependencies, setDependencies] = useState([]); const [showDeps, setShowDeps] = useState(false); const [depSearch, setDepSearch] = useState(""); const [isModelModalOpen, setIsModelModalOpen] = useState(false); const [executorProvider, setExecutorProvider] = useState(undefined); const [executorModelId, setExecutorModelId] = useState(undefined); const [validatorProvider, setValidatorProvider] = useState(undefined); const [validatorModelId, setValidatorModelId] = useState(undefined); const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState(null); const [loadedModels, setLoadedModels] = useState(availableModels ?? []); // AI Refinement state const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false); const [isRefining, setIsRefining] = useState(false); const refineMenuRef = useRef(null); // 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((models) => { if (!cancelled) { setLoadedModels(models); } }) .catch((err: any) => { if (!cancelled) { setModelsError(err?.message || "Failed to load models"); } }) .finally(() => { if (!cancelled) { setModelsLoading(false); } }); return () => { cancelled = true; }; }, [availableModels]); const executorSelectionValue = getModelSelectionValue(executorProvider, executorModelId); const validatorSelectionValue = getModelSelectionValue(validatorProvider, validatorModelId); const hasExecutorOverride = Boolean(executorProvider && executorModelId); const hasValidatorOverride = Boolean(validatorProvider && validatorModelId); const selectedModelCount = Number(hasExecutorOverride) + Number(hasValidatorOverride); const getModelBadgeLabel = useCallback( (provider?: string, modelId?: string) => { if (!provider || !modelId) return "Using default"; const matched = loadedModels.find((model) => model.provider === provider && model.id === modelId); return matched ? `${matched.provider}/${matched.id}` : `${provider}/${modelId}`; }, [loadedModels], ); // Persist description to localStorage whenever it changes useEffect(() => { if (typeof window !== "undefined") { localStorage.setItem(STORAGE_KEY, description); } }, [description]); // Cleanup timeout on unmount useEffect(() => { return () => { if (blurTimeoutRef.current) { clearTimeout(blurTimeoutRef.current); } }; }, []); // 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 useEffect(() => { if (isExpanded) { autoResize(); } }, [description, isExpanded, autoResize]); // Restore focus after submission completes (when textarea is re-enabled) useEffect(() => { if (!isSubmitting && description === "" && textareaRef.current) { // Use setTimeout to ensure focus happens after React re-enables the textarea const focusTimeout = setTimeout(() => { textareaRef.current?.focus(); }, 0); return () => clearTimeout(focusTimeout); } }, [isSubmitting, description]); // Clear dep search when dropdown closes useEffect(() => { if (!showDeps) setDepSearch(""); }, [showDeps]); // 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]); const resetForm = useCallback(() => { setDescription(""); setDependencies([]); setExecutorProvider(undefined); setExecutorModelId(undefined); setValidatorProvider(undefined); setValidatorModelId(undefined); setShowDeps(false); setIsModelModalOpen(false); setIsRefineMenuOpen(false); setIsRefining(false); setIsExpanded(false); justResetRef.current = true; if (textareaRef.current) { textareaRef.current.style.height = "auto"; } // Clear localStorage when form is reset (after successful creation) if (typeof window !== "undefined") { localStorage.removeItem(STORAGE_KEY); } }, []); const handleSubmit = useCallback(async () => { const trimmed = description.trim(); if (!trimmed || isSubmitting || !onCreate) return; setIsSubmitting(true); try { await onCreate({ description: trimmed, column: "triage", dependencies: dependencies.length ? dependencies : undefined, modelProvider: hasExecutorOverride ? executorProvider : undefined, modelId: hasExecutorOverride ? executorModelId : undefined, validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined, validatorModelId: hasValidatorOverride ? validatorModelId : undefined, }); // Clear input for rapid entry resetForm(); // Note: Focus restoration is handled by useEffect when isSubmitting becomes false } catch (err: any) { addToast(err.message || "Failed to create task", "error"); // Keep input content on failure so user can retry } finally { setIsSubmitting(false); } }, [ description, isSubmitting, onCreate, dependencies, hasExecutorOverride, executorProvider, executorModelId, hasValidatorOverride, validatorProvider, validatorModelId, addToast, resetForm, ]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter") { if (e.shiftKey && isExpanded) { // Allow Shift+Enter to insert newline when expanded // Don't prevent default - let the newline be inserted return; } // Enter without Shift submits e.preventDefault(); handleSubmit(); } else if (e.key === "Escape") { e.preventDefault(); // Close modal first if open if (isModelModalOpen) { setIsModelModalOpen(false); return; } // Close dropdowns first if open if (showDeps || isRefineMenuOpen) { setShowDeps(false); setIsRefineMenuOpen(false); 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") { localStorage.removeItem(STORAGE_KEY); } } // Collapse on escape resetForm(); // Clear any pending blur timeout if (blurTimeoutRef.current) { clearTimeout(blurTimeoutRef.current); blurTimeoutRef.current = null; } textareaRef.current?.blur(); } }, [handleSubmit, description, isExpanded, showDeps, isModelModalOpen, isRefineMenuOpen, resetForm], ); const handleFocus = useCallback(() => { // Skip expanding if we just reset the form (prevents controls showing after successful creation) if (justResetRef.current) { justResetRef.current = false; return; } setIsExpanded(true); }, []); const handleBlur = useCallback(() => { // Clear any existing timeout if (blurTimeoutRef.current) { clearTimeout(blurTimeoutRef.current); } // Collapse after a short delay to allow click events on dropdowns // Collapse regardless of content - only check if dropdowns are open blurTimeoutRef.current = setTimeout(() => { if (!showDeps && !isModelModalOpen && !isRefineMenuOpen) { setIsExpanded(false); // Reset height when collapsing if (textareaRef.current) { textareaRef.current.style.height = "auto"; } } blurTimeoutRef.current = null; }, 200); }, [showDeps, isModelModalOpen, isRefineMenuOpen]); const toggleDep = useCallback((id: string) => { setDependencies((prev) => prev.includes(id) ? prev.filter((d) => d !== id) : [...prev, id], ); }, []); const toggleDepsDropdown = useCallback(() => { setShowDeps((prev) => { const next = !prev; if (next) setIsModelModalOpen(false); return next; }); }, []); const openModelModal = useCallback(() => { setIsModelModalOpen(true); setShowDeps(false); }, []); 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 handlePlanClick = useCallback(() => { const trimmed = description.trim(); if (!trimmed) { addToast("Enter a description first", "error"); return; } onPlanningMode?.(trimmed); // Clear the form after triggering planning mode resetForm(); }, [description, onPlanningMode, addToast, resetForm]); const handleSubtaskClick = useCallback(() => { const trimmed = description.trim(); if (!trimmed) { addToast("Enter a description first", "error"); return; } onSubtaskBreakdown?.(trimmed); // Clear the form after triggering subtask breakdown resetForm(); }, [description, onSubtaskBreakdown, addToast, resetForm]); const handleSaveClick = useCallback(() => { // Save button now creates the task (same as Enter key) handleSubmit(); }, [handleSubmit]); const handleRefine = useCallback(async (type: RefinementType) => { const trimmed = description.trim(); if (!trimmed || isRefining) return; setIsRefining(true); try { const refined = await refineText(trimmed, type); setDescription(refined); setIsRefineMenuOpen(false); addToast("Description refined with AI", "success"); // Auto-resize textarea after content update if (textareaRef.current) { textareaRef.current.style.height = "auto"; textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`; } } catch (err: any) { const errorMessage = getRefineErrorMessage(err); addToast(errorMessage, "error"); } finally { setIsRefining(false); } }, [description, isRefining, addToast]); 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 { setLoadedModels(await fetchModels()); } catch (err: any) { setModelsError(err?.message || "Failed to load models"); } finally { setModelsLoading(false); } }, [availableModels]); // Show expanded controls only when focused/interacted (isExpanded) const showExpandedControls = isExpanded; return (