import { useState, useCallback, useEffect, useRef, useMemo } from "react"; import type { Task, TaskCreateInput, ModelPreset, Settings, WorkflowStep } from "@kb/core"; import type { ToastType } from "../hooks/useToast"; import { uploadAttachment, fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, type RefinementType } from "../api"; import type { ModelInfo } from "../api"; import { filterModels } from "../utils/modelFilter"; import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets"; import { ProviderIcon } from "./ProviderIcon"; import { Sparkles } from "lucide-react"; const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; interface PendingImage { file: File; previewUrl: string; } interface NewTaskModalProps { isOpen: boolean; onClose: () => void; tasks: Task[]; // for dependency selection onCreateTask: (input: TaskCreateInput) => Promise; addToast: (message: string, type?: ToastType) => void; onPlanningMode?: (initialPlan: string) => void; onSubtaskBreakdown?: (description: string) => void; } /** * Simplified ModelCombobox for the New Task modal. * Reuses the same interaction pattern as ModelSelectorTab. */ function ModelCombobox({ value, onChange, models, disabled = false, placeholder = "Select a model…", label, id, }: { value: string; onChange: (value: string) => void; models: ModelInfo[]; disabled?: boolean; placeholder?: string; label: string; id: string; }) { const [isOpen, setIsOpen] = useState(false); const [localFilter, setLocalFilter] = useState(""); const [highlightedIndex, setHighlightedIndex] = useState(0); const containerRef = useRef(null); const searchInputRef = useRef(null); const listRef = useRef(null); const filteredModels = filterModels(models, localFilter); const modelsByProvider = filteredModels.reduce>((acc, m) => { (acc[m.provider] ??= []).push(m); return acc; }, {}); // Get current provider from value for icon display const currentProvider = useMemo(() => { if (!value) return null; const slashIdx = value.indexOf("/"); return slashIdx === -1 ? null : value.slice(0, slashIdx); }, [value]); const optionsList = [ { type: "default" as const, value: "", label: "Use default" }, ...Object.entries(modelsByProvider).flatMap(([provider, providerModels]) => [ { type: "provider" as const, value: `__group_${provider}`, label: provider, provider }, ...providerModels.map((m) => ({ type: "model" as const, value: `${m.provider}/${m.id}`, label: m.name, provider: m.provider })), ]), ]; const selectedDisplayText = !value ? "Use default" : (() => { const slashIdx = value.indexOf("/"); if (slashIdx === -1) return value; const provider = value.slice(0, slashIdx); const modelId = value.slice(slashIdx + 1); const model = models.find((m) => m.provider === provider && m.id === modelId); return model?.name || value; })(); const currentValueIndex = optionsList.findIndex((opt) => opt.value === value); useEffect(() => { if (isOpen) { const selectableIndex = optionsList.findIndex((opt, idx) => idx >= (currentValueIndex >= 0 ? currentValueIndex : 0) && opt.type !== "provider" ); setHighlightedIndex(selectableIndex >= 0 ? selectableIndex : 0); setTimeout(() => searchInputRef.current?.focus(), 0); } }, [isOpen, optionsList, currentValueIndex]); useEffect(() => { if (!isOpen) return; const handleClickOutside = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setIsOpen(false); setLocalFilter(""); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [isOpen]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { switch (e.key) { case "ArrowDown": e.preventDefault(); if (!isOpen) { setIsOpen(true); } else { let nextIndex = highlightedIndex; for (let i = 1; i <= optionsList.length; i++) { const idx = (highlightedIndex + i) % optionsList.length; if (optionsList[idx]?.type !== "provider") { nextIndex = idx; break; } } setHighlightedIndex(nextIndex); } break; case "ArrowUp": e.preventDefault(); if (isOpen) { let prevIndex = highlightedIndex; for (let i = 1; i <= optionsList.length; i++) { const idx = (highlightedIndex - i + optionsList.length) % optionsList.length; if (optionsList[idx]?.type !== "provider") { prevIndex = idx; break; } } setHighlightedIndex(prevIndex); } break; case "Enter": e.preventDefault(); if (isOpen) { const option = optionsList[highlightedIndex]; if (option && option.type !== "provider") { onChange(option.value); setIsOpen(false); setLocalFilter(""); } } else { setIsOpen(true); } break; case "Escape": e.preventDefault(); setIsOpen(false); setLocalFilter(""); break; case "Tab": if (isOpen) { setIsOpen(false); setLocalFilter(""); } break; } }, [isOpen, highlightedIndex, optionsList, onChange]); const handleSelect = useCallback((optionValue: string) => { onChange(optionValue); setIsOpen(false); setLocalFilter(""); }, [onChange]); useEffect(() => { if (isOpen && listRef.current) { const highlightedEl = listRef.current.querySelector(`[data-index="${highlightedIndex}"]`); if (highlightedEl && typeof highlightedEl.scrollIntoView === "function") { highlightedEl.scrollIntoView({ block: "nearest" }); } } }, [highlightedIndex, isOpen]); return (
{isOpen && (
setLocalFilter(e.target.value)} onClick={(e) => e.stopPropagation()} /> {localFilter && ( )}
{filteredModels.length} model{filteredModels.length !== 1 ? "s" : ""}
handleSelect("")} onMouseEnter={() => setHighlightedIndex(0)} role="option" aria-selected={value === ""} > Use default
{Object.entries(modelsByProvider).map(([provider, providerModels]) => { const groupStartIndex = optionsList.findIndex((opt) => opt.value === `__group_${provider}`); return (
{provider}
{providerModels.map((m) => { const optionValue = `${m.provider}/${m.id}`; const optionIndex = optionsList.findIndex((opt) => opt.value === optionValue); const isHighlighted = highlightedIndex === optionIndex; const isSelected = value === optionValue; return (
handleSelect(optionValue)} onMouseEnter={() => setHighlightedIndex(optionIndex)} role="option" aria-selected={isSelected} > {m.name} {m.id}
); })}
); })} {filteredModels.length === 0 && localFilter && (
No models match '{localFilter}'
)}
)}
); } export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) { const [description, setDescription] = useState(""); const [dependencies, setDependencies] = useState([]); const [showDepDropdown, setShowDepDropdown] = useState(false); const [depSearch, setDepSearch] = useState(""); const [pendingImages, setPendingImages] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [availableModels, setAvailableModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [executorModel, setExecutorModel] = useState(""); const [validatorModel, setValidatorModel] = useState(""); const [settings, setSettings] = useState(null); const [selectedPresetId, setSelectedPresetId] = useState(""); const [presetMode, setPresetMode] = useState<"default" | "preset" | "custom">("default"); const [hasDirtyState, setHasDirtyState] = useState(false); const [workflowSteps, setWorkflowSteps] = useState([]); const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState([]); // AI Refinement state const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false); const [isRefining, setIsRefining] = useState(false); const refineMenuRef = useRef(null); const depDropdownRef = useRef(null); const descTextareaRef = useRef(null); const fileInputRef = useRef(null); // Load available models when modal opens useEffect(() => { if (isOpen) { setModelsLoading(true); fetchModels() .then((models) => setAvailableModels(models)) .catch(() => {/* silently fail - models just won't be available */}) .finally(() => setModelsLoading(false)); fetchSettings() .then((nextSettings) => setSettings(nextSettings)) .catch(() => setSettings(null)); fetchWorkflowSteps() .then((steps) => setWorkflowSteps(steps.filter((s) => s.enabled))) .catch(() => setWorkflowSteps([])); } }, [isOpen]); // Track dirty state useEffect(() => { const isDirty = description.trim() !== "" || dependencies.length > 0 || pendingImages.length > 0 || executorModel !== "" || validatorModel !== "" || selectedWorkflowSteps.length > 0; setHasDirtyState(isDirty); }, [description, dependencies, pendingImages, executorModel, validatorModel, selectedWorkflowSteps]); const availablePresets = settings?.modelPresets || []; const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId); useEffect(() => { if (!isOpen || !settings?.autoSelectModelPreset) return; const recommended = getRecommendedPresetForSize(undefined, settings.defaultPresetBySize || {}, availablePresets); if (recommended) { const selection = applyPresetToSelection(recommended); setSelectedPresetId(recommended.id); setPresetMode("preset"); setExecutorModel(selection.executorValue); setValidatorModel(selection.validatorValue); } }, [isOpen, settings, availablePresets]); // Auto-focus description textarea when modal opens useEffect(() => { if (isOpen) { // Small delay to ensure modal is fully rendered const timeoutId = setTimeout(() => { descTextareaRef.current?.focus(); }, 0); return () => clearTimeout(timeoutId); } }, [isOpen]); // Close dropdown when clicking outside useEffect(() => { if (!showDepDropdown) return; const handleClickOutside = (e: MouseEvent) => { if (depDropdownRef.current && !depDropdownRef.current.contains(e.target as Node)) { setShowDepDropdown(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [showDepDropdown]); // Close refine menu when clicking outside useEffect(() => { if (!isRefineMenuOpen) return; const handleClickOutside = (e: MouseEvent) => { if (refineMenuRef.current && !refineMenuRef.current.contains(e.target as Node)) { setIsRefineMenuOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [isRefineMenuOpen]); // Handle paste for images const handlePaste = useCallback((e: React.ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.type.startsWith("image/")) { const file = item.getAsFile(); if (file && ALLOWED_IMAGE_TYPES.includes(file.type)) { e.preventDefault(); setPendingImages((prev) => [ ...prev, { file, previewUrl: URL.createObjectURL(file) }, ]); return; } } } }, []); // Handle file drop for images const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); const files = e.dataTransfer.files; for (let i = 0; i < files.length; i++) { const file = files[i]; if (ALLOWED_IMAGE_TYPES.includes(file.type)) { setPendingImages((prev) => [ ...prev, { file, previewUrl: URL.createObjectURL(file) }, ]); return; } } }, []); const removeImage = useCallback((index: number) => { setPendingImages((prev) => { const removed = prev[index]; if (removed) URL.revokeObjectURL(removed.previewUrl); return prev.filter((_, i) => i !== index); }); }, []); const toggleDep = useCallback((id: string) => { setDependencies((prev) => prev.includes(id) ? prev.filter((d) => d !== id) : [...prev, id], ); }, []); const truncate = (s: string, len: number) => s.length > len ? s.slice(0, len) + "…" : s; const handleClose = useCallback(() => { if (hasDirtyState) { if (!confirm("You have unsaved changes. Discard them?")) return; } // Clean up object URLs pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); setPendingImages([]); // Reset form setPendingImages([]); setDescription(""); setDependencies([]); setExecutorModel(""); setValidatorModel(""); setSelectedPresetId(""); setPresetMode("default"); setSelectedWorkflowSteps([]); setIsRefineMenuOpen(false); setIsRefining(false); setHasDirtyState(false); onClose(); }, [hasDirtyState, onClose, pendingImages]); const handleSubmit = useCallback(async () => { const trimmedDesc = description.trim(); if (!trimmedDesc || isSubmitting) return; setIsSubmitting(true); try { // Create the base task const executorSlashIdx = executorModel.indexOf("/"); const validatorSlashIdx = validatorModel.indexOf("/"); const task = await onCreateTask({ title: undefined, description: trimmedDesc, column: "triage", dependencies: dependencies.length ? dependencies : undefined, enabledWorkflowSteps: selectedWorkflowSteps.length > 0 ? selectedWorkflowSteps : undefined, modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined, modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined, modelId: executorModel && executorSlashIdx !== -1 ? executorModel.slice(executorSlashIdx + 1) : undefined, validatorModelProvider: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(0, validatorSlashIdx) : undefined, validatorModelId: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(validatorSlashIdx + 1) : 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 pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); setPendingImages([]); setDescription(""); setDependencies([]); setExecutorModel(""); setValidatorModel(""); setSelectedPresetId(""); setPresetMode("default"); setSelectedWorkflowSteps([]); addToast(`Created ${task.id}`, "success"); onClose(); } catch (err: any) { addToast(err.message || "Failed to create task", "error"); } finally { setIsSubmitting(false); } }, [description, dependencies, pendingImages, executorModel, validatorModel, isSubmitting, onCreateTask, addToast, onClose]); // Handle keyboard shortcuts const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === "Escape" && !showDepDropdown) { e.preventDefault(); handleClose(); } }, [handleClose, showDepDropdown]); // Auto-resize textarea const handleDescriptionChange = useCallback((e: React.ChangeEvent) => { setDescription(e.target.value); const el = e.target; el.style.height = "auto"; el.style.height = el.scrollHeight + "px"; }, []); // AI Refinement handler const handleRefine = useCallback(async (type: RefinementType) => { const trimmed = description.trim(); if (!trimmed || isRefining) return; setIsRefining(true); try { const refined = await refineText(trimmed, type); setDescription(refined); setIsRefineMenuOpen(false); addToast("Description refined with AI", "success"); // Auto-resize textarea after content update if (descTextareaRef.current) { descTextareaRef.current.style.height = "auto"; descTextareaRef.current.style.height = descTextareaRef.current.scrollHeight + "px"; } } catch (err: any) { const errorMessage = getRefineErrorMessage(err); addToast(errorMessage, "error"); } finally { setIsRefining(false); } }, [description, isRefining, addToast]); if (!isOpen) return null; const availableDeps = tasks .filter((t) => !dependencies.includes(t.id)) .sort((a, b) => { const cmp = b.createdAt.localeCompare(a.createdAt); if (cmp !== 0) return cmp; const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0; const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0; return bNum - aNum; }); const filteredDeps = depSearch ? availableDeps.filter((t) => t.id.toLowerCase().includes(depSearch.toLowerCase()) || (t.title && t.title.toLowerCase().includes(depSearch.toLowerCase())) || (t.description && t.description.toLowerCase().includes(depSearch.toLowerCase())) ) : availableDeps; return (
e.stopPropagation()} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()} onPaste={handlePaste} >

New Task

{/* Description field */}