import { useState, useCallback, useEffect, useRef } from "react"; import { Brain, Link, Lightbulb, ListTree, Zap } from "lucide-react"; import type { Task, TaskCreateInput, Settings } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; import { fetchModels, uploadAttachment, fetchSettings } from "../api"; import type { ModelInfo } from "../api"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { applyPresetToSelection } from "../utils/modelPresets"; const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; const STORAGE_KEY = "kb-inline-create-text"; interface PendingImage { file: File; previewUrl: string; } interface InlineCreateCardProps { tasks: Task[]; onSubmit: (input: TaskCreateInput) => Promise; onCancel: () => void; addToast: (msg: string, type?: ToastType) => void; /** * Optional model list from a parent surface. When omitted, InlineCreateCard * fetches models itself so it can stay reusable in both list and board flows * without forcing model data to be threaded through every caller. */ 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 InlineCreateCard({ tasks, onSubmit, onCancel, addToast, availableModels, onPlanningMode, onSubtaskBreakdown, }: InlineCreateCardProps) { const [description, setDescription] = useState(() => { if (typeof window !== "undefined") { return localStorage.getItem(STORAGE_KEY) || ""; } return ""; }); const [dependencies, setDependencies] = useState([]); const [showDeps, setShowDeps] = useState(false); const [depSearch, setDepSearch] = useState(""); const [showModels, setShowModels] = useState(false); const [showPresets, setShowPresets] = useState(false); const [settings, setSettings] = useState(null); const [selectedPresetId, setSelectedPresetId] = useState(undefined); 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 ?? []); const [submitting, setSubmitting] = useState(false); const [pendingImages, setPendingImages] = useState([]); const inputRef = useRef(null); const cardRef = useRef(null); // Persist description to localStorage whenever it changes useEffect(() => { if (typeof window !== "undefined") { localStorage.setItem(STORAGE_KEY, description); } }, [description]); 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]); useEffect(() => { inputRef.current?.focus(); }, []); useEffect(() => { if (!showDeps) setDepSearch(""); }, [showDeps]); useEffect(() => { let cancelled = false; if (availableModels) { setLoadedModels(availableModels); setModelsLoading(false); setModelsError(null); return; } 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); } }); fetchSettings() .then((nextSettings) => { if (!cancelled) { setSettings(nextSettings); } }) .catch(() => { if (!cancelled) { setSettings(null); } }); return () => { cancelled = true; }; }, [availableModels]); const executorSelectionValue = getModelSelectionValue(executorProvider, executorModelId); const validatorSelectionValue = getModelSelectionValue(validatorProvider, validatorModelId); const availablePresets = settings?.modelPresets || []; const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId); 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], ); // Cancel when focus leaves the card entirely and there's no content useEffect(() => { const card = cardRef.current; if (!card) return; const handleFocusOut = (e: FocusEvent) => { // relatedTarget is the element receiving focus — if it's inside the card, ignore if (e.relatedTarget instanceof Node && card.contains(e.relatedTarget)) return; // Only cancel if empty and dropdowns are not open if ( description.trim() === "" && pendingImages.length === 0 && dependencies.length === 0 && !hasExecutorOverride && !hasValidatorOverride && !showDeps && !showModels && !showPresets ) { onCancel(); } }; card.addEventListener("focusout", handleFocusOut); return () => card.removeEventListener("focusout", handleFocusOut); }, [ description, pendingImages, dependencies, hasExecutorOverride, hasValidatorOverride, showDeps, showModels, showPresets, onCancel, ]); // Clean up object URLs on unmount to prevent memory leaks useEffect(() => { return () => { pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); // Clear localStorage on unmount if there's no description (user abandoned) if (typeof window !== "undefined") { const current = localStorage.getItem(STORAGE_KEY); if (current && current.trim() === "") { localStorage.removeItem(STORAGE_KEY); } } }; // eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount }, []); /** * Handles paste events on the textarea. Extracts image files from the * clipboard data, creates object URL previews, and appends them to * the pendingImages state. Non-image files are silently ignored. */ const handlePaste = useCallback( (e: React.ClipboardEvent) => { if (submitting) return; const files = e.clipboardData?.files; 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]); } }, [submitting], ); const removeImage = useCallback((index: number) => { setPendingImages((prev) => { const removed = prev[index]; if (removed) URL.revokeObjectURL(removed.previewUrl); return prev.filter((_, i) => i !== index); }); }, []); const handleSubmit = useCallback(async () => { if (!description.trim() || submitting) return; setSubmitting(true); try { const task = await onSubmit({ description: description.trim(), column: "triage", dependencies: dependencies.length ? dependencies : undefined, modelPresetId: selectedPresetId, modelProvider: hasExecutorOverride ? executorProvider : undefined, modelId: hasExecutorOverride ? executorModelId : undefined, validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined, validatorModelId: hasValidatorOverride ? validatorModelId : undefined, }); // Upload pending images as attachments if (pendingImages.length > 0) { const failures: string[] = []; for (const img of pendingImages) { try { await uploadAttachment(task.id, img.file); } catch { failures.push(img.file.name); } } if (failures.length > 0) { addToast(`Failed to upload: ${failures.join(", ")}`, "error"); } } // Clean up preview URLs pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); setPendingImages([]); setSelectedPresetId(undefined); addToast(`Created ${task.id}`, "success"); // Clear localStorage after successful task creation if (typeof window !== "undefined") { localStorage.removeItem(STORAGE_KEY); } } catch (err: any) { addToast(err.message, "error"); } finally { setSubmitting(false); } }, [ description, dependencies, hasExecutorOverride, executorProvider, executorModelId, hasValidatorOverride, validatorProvider, validatorModelId, submitting, pendingImages, onSubmit, addToast, ]); const handleKeyDown = useCallback( async (e: React.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); // Clear localStorage when user explicitly cancels if (typeof window !== "undefined") { localStorage.removeItem(STORAGE_KEY); } onCancel(); return; } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }, [handleSubmit, onCancel], ); 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) setShowModels(false); return next; }); }, []); const toggleModelsDropdown = useCallback(() => { setShowModels((prev) => { const next = !prev; if (next) setShowDeps(false); return next; }); }, []); 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 handleModelDropdownMouseDown = useCallback((e: React.MouseEvent) => { const target = e.target; if ( target instanceof HTMLElement && (target.closest("button") || target.closest("input")) ) { return; } e.preventDefault(); }, []); const handlePlanClick = useCallback(() => { const trimmed = description.trim(); if (!trimmed) { addToast("Enter a description first", "error"); return; } onPlanningMode?.(trimmed); // Clear the input after triggering planning mode setDescription(""); setDependencies([]); setExecutorProvider(undefined); setExecutorModelId(undefined); setValidatorProvider(undefined); setValidatorModelId(undefined); setSelectedPresetId(undefined); setShowDeps(false); setShowModels(false); setShowPresets(false); }, [description, onPlanningMode, addToast]); const handleSubtaskClick = useCallback(() => { const trimmed = description.trim(); if (!trimmed) { addToast("Enter a description first", "error"); return; } onSubtaskBreakdown?.(trimmed); // Clear the input after triggering subtask breakdown setDescription(""); setDependencies([]); setExecutorProvider(undefined); setExecutorModelId(undefined); setValidatorProvider(undefined); setValidatorModelId(undefined); setSelectedPresetId(undefined); setShowDeps(false); setShowModels(false); setShowPresets(false); }, [description, onSubtaskBreakdown, addToast]); const truncate = (s: string, len: number) => s.length > len ? s.slice(0, len) + "…" : s; return (