import "./InlineCreateCard.css"; import { useState, useCallback, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { Brain, Link, Lightbulb, ListTree, Zap, ChevronDown, ChevronUp, Bot, Maximize2, Minimize2, Server } from "lucide-react"; import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type Task, type TaskCreateInput, type TaskPriority, type Settings } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; import { fetchModels, uploadAttachment, fetchSettings, updateGlobalSettings, fetchAgents } from "../api"; import type { ModelInfo, Agent, NodeInfo } from "../api"; import { useNodes } from "../hooks/useNodes"; import { ModelSelectionModal } from "./ModelSelectionModal"; import { NodeHealthDot } from "./NodeHealthDot"; import { applyPresetToSelection } from "../utils/modelPresets"; import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage"; 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; projectId?: string; /** * 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 getNodeStatusLabel(status: NodeInfo["status"]): string { if (status === "online") return "Online"; if (status === "connecting") return "Connecting"; if (status === "error") return "Error"; return "Offline"; } 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, projectId, availableModels, onPlanningMode, onSubtaskBreakdown, }: InlineCreateCardProps) { const [description, setDescription] = useState(() => { if (typeof window !== "undefined") { return getScopedItem(STORAGE_KEY, projectId) || ""; } return ""; }); const [dependencies, setDependencies] = useState([]); const [showDeps, setShowDeps] = useState(false); const [depSearch, setDepSearch] = useState(""); const [selectedAgentId, setSelectedAgentId] = useState(null); const [agents, setAgents] = useState([]); const [showAgentPicker, setShowAgentPicker] = useState(false); const [showNodePicker, setShowNodePicker] = useState(false); const [agentsLoading, setAgentsLoading] = useState(false); const [isModelModalOpen, setIsModelModalOpen] = useState(false); const [showPresets, setShowPresets] = useState(false); const [nodeId, setNodeId] = useState(undefined); const { nodes } = useNodes(); 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 [browserVerification, setBrowserVerification] = useState(false); const [priority, setPriority] = useState(DEFAULT_TASK_PRIORITY); const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState(null); const [loadedModels, setLoadedModels] = useState(availableModels ?? []); const [favoriteProviders, setFavoriteProviders] = useState([]); const [favoriteModels, setFavoriteModels] = useState([]); const [submitting, setSubmitting] = useState(false); const [pendingImages, setPendingImages] = useState([]); const [isExpanded, setIsExpanded] = useState(false); // isDescriptionExpanded controls fullscreen description editing mode const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); // Track textarea focus for expand button visibility const [isDescriptionFocused, setIsDescriptionFocused] = useState(false); const justResetRef = useRef(false); const inputRef = useRef(null); const cardRef = useRef(null); const agentPickerRef = useRef(null); const nodePickerRef = useRef(null); 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(() => { setAgents([]); setSelectedAgentId(null); }, [projectId]); 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); setFavoriteProviders(response.favoriteProviders); setFavoriteModels(response.favoriteModels); } catch (err) { setModelsError(getErrorMessage(err) || "Failed to load models"); } finally { setModelsLoading(false); } }, [availableModels]); useEffect(() => { inputRef.current?.focus(); }, []); useEffect(() => { if (!showDeps) setDepSearch(""); }, [showDeps]); useEffect(() => { if (!showAgentPicker) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node; if (agentPickerRef.current?.contains(target)) return; setShowAgentPicker(false); }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [showAgentPicker]); useEffect(() => { if (!showNodePicker) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node; if (nodePickerRef.current?.contains(target)) return; setShowNodePicker(false); }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [showNodePicker]); useEffect(() => { let cancelled = false; if (availableModels) { setLoadedModels(availableModels); setModelsLoading(false); setModelsError(null); return; } setModelsLoading(true); setModelsError(null); fetchModels() .then((response) => { if (!cancelled) { setLoadedModels(response.models); setFavoriteProviders(response.favoriteProviders); setFavoriteModels(response.favoriteModels); } }) .catch((err) => { if (!cancelled) { setModelsError(getErrorMessage(err) || "Failed to load models"); } }) .finally(() => { if (!cancelled) { setModelsLoading(false); } }); fetchSettings(projectId) .then((nextSettings) => { if (!cancelled) { setSettings(nextSettings); } }) .catch(() => { if (!cancelled) { setSettings(null); } }); return () => { cancelled = true; }; }, [availableModels, projectId]); 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); // Track focus-out for conditional cancel behavior and justResetRef cleanup. 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; if (justResetRef.current) { justResetRef.current = false; return; } const hasOpenOverlay = showDeps || showAgentPicker || isModelModalOpen || showPresets; const hasDescription = description.trim().length > 0; const shouldCancelWhenCollapsed = !isExpanded && !hasDescription && !hasOpenOverlay; const shouldCancelWhenExpanded = isExpanded && !hasDescription && !hasOpenOverlay; if (shouldCancelWhenCollapsed || shouldCancelWhenExpanded) { onCancel(); } }; card.addEventListener("focusout", handleFocusOut); return () => card.removeEventListener("focusout", handleFocusOut); }, [description, isExpanded, onCancel, showDeps, showAgentPicker, isModelModalOpen, showPresets]); // 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 = getScopedItem(STORAGE_KEY, projectId); if (current && current.trim() === "") { removeScopedItem(STORAGE_KEY, projectId); } } }; }, [projectId]); /** * 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, ...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}), modelPresetId: selectedPresetId, modelProvider: hasExecutorOverride ? executorProvider : undefined, modelId: hasExecutorOverride ? executorModelId : undefined, validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined, validatorModelId: hasValidatorOverride ? validatorModelId : undefined, enabledWorkflowSteps: browserVerification ? ["browser-verification"] : undefined, priority, nodeId, }); // Upload pending images as attachments if (pendingImages.length > 0) { const failures: string[] = []; for (const img of pendingImages) { try { await uploadAttachment(task.id, img.file, projectId); } catch { failures.push(img.file.name); } } if (failures.length > 0) { addToast(`Failed to upload: ${failures.join(", ")}`, "error"); } } // Clean up preview URLs pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); setPendingImages([]); setDescription(""); if (inputRef.current) { inputRef.current.style.height = "auto"; } setSelectedPresetId(undefined); setExecutorProvider(undefined); setExecutorModelId(undefined); setValidatorProvider(undefined); setValidatorModelId(undefined); setBrowserVerification(false); setPriority(DEFAULT_TASK_PRIORITY); setDependencies([]); setSelectedAgentId(null); setNodeId(undefined); setShowDeps(false); setShowNodePicker(false); setShowAgentPicker(false); setIsModelModalOpen(false); setShowPresets(false); addToast(`Created ${task.id}`, "success"); // Collapse and clear localStorage after successful task creation setIsExpanded(false); setIsDescriptionExpanded(false); // Exit fullscreen mode on submit justResetRef.current = true; if (typeof window !== "undefined") { removeScopedItem(STORAGE_KEY, projectId); } } catch (err) { addToast(getErrorMessage(err), "error"); } finally { setSubmitting(false); } }, [ description, dependencies, selectedAgentId, hasExecutorOverride, executorProvider, executorModelId, hasValidatorOverride, validatorProvider, validatorModelId, browserVerification, priority, submitting, pendingImages, onSubmit, addToast, projectId, selectedPresetId, nodeId, ]); const handleKeyDown = useCallback( async (e: React.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); // Exit fullscreen mode first - highest priority if (isDescriptionExpanded) { setIsDescriptionExpanded(false); return; } // Close dropdowns first if open if (showDeps || showNodePicker || showAgentPicker || isModelModalOpen || showPresets) { setShowDeps(false); setShowNodePicker(false); setShowAgentPicker(false); setIsModelModalOpen(false); setShowPresets(false); return; } // Clear non-empty input on Escape and clear localStorage if (description.trim()) { setDescription(""); // Reset height if (inputRef.current) { inputRef.current.style.height = "auto"; } // Clear localStorage when user explicitly clears input if (typeof window !== "undefined") { removeScopedItem(STORAGE_KEY, projectId); } } // Collapse and cancel on escape setIsExpanded(false); onCancel(); return; } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }, [ handleSubmit, onCancel, description, isDescriptionExpanded, showDeps, showNodePicker, showAgentPicker, isModelModalOpen, showPresets, projectId, ], ); 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); setShowNodePicker(false); setShowAgentPicker(false); } return next; }); }, []); const toggleModelsDropdown = useCallback(() => { setIsModelModalOpen(true); setShowDeps(false); setShowNodePicker(false); setShowAgentPicker(false); }, []); const loadAgents = useCallback(async () => { if (agents.length > 0) { setShowNodePicker(false); setShowAgentPicker(true); return; } setAgentsLoading(true); try { const result = await fetchAgents(undefined, projectId); setAgents(result); setShowNodePicker(false); setShowAgentPicker(true); } catch (err) { const msg = getErrorMessage(err); addToast(msg ? `Failed to load agents: ${msg}` : "Failed to load agents", "error"); setShowAgentPicker(false); } finally { setAgentsLoading(false); } }, [agents.length, projectId, addToast]); const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined; const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId; const selectedNode = nodeId ? nodes.find((node) => node.id === nodeId) : undefined; 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 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 { // Revert on error 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 { // Revert on error setFavoriteModels(currentFavorites); } }, [favoriteModels, favoriteProviders]); 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); setBrowserVerification(false); setSelectedPresetId(undefined); setSelectedAgentId(null); setNodeId(undefined); setShowDeps(false); setShowAgentPicker(false); setIsModelModalOpen(false); setShowPresets(false); setIsExpanded(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); setBrowserVerification(false); setSelectedPresetId(undefined); setSelectedAgentId(null); setNodeId(undefined); setShowDeps(false); setShowAgentPicker(false); setIsModelModalOpen(false); setShowPresets(false); setIsExpanded(false); }, [description, onSubtaskBreakdown, addToast]); const truncate = (s: string, len: number) => s.length > len ? s.slice(0, len) + "…" : s; const toggleExpanded = useCallback(() => { setIsExpanded((prev) => !prev); }, []); const handleToggleDescriptionExpand = useCallback(() => { setIsDescriptionExpanded((prev) => { const next = !prev; // Focus the fullscreen textarea after it renders if (next && inputRef.current) { setTimeout(() => inputRef.current?.focus(), 0); } return next; }); }, []); const handleDescriptionFullscreenKeyDown = useCallback((e: React.KeyboardEvent) => { if (!isDescriptionExpanded || e.key !== "Escape") return; e.preventDefault(); e.stopPropagation(); setIsDescriptionExpanded(false); }, [isDescriptionExpanded]); return (
{isDescriptionExpanded && (
Editing Description
)} {!isDescriptionExpanded && (