import { useState, useCallback, useEffect, useRef } from "react"; import type { Task, TaskCreateInput } from "@kb/core"; import type { ToastType } from "../hooks/useToast"; import { uploadAttachment, fetchModels, updateTask } from "../api"; import type { ModelInfo } from "../api"; import { filterModels } from "../utils/modelFilter"; 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; } /** * 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; }, {}); 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 }: 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 [enablePlanningMode, setEnablePlanningMode] = useState(false); const [hasDirtyState, setHasDirtyState] = useState(false); 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)); } }, [isOpen]); // Track dirty state useEffect(() => { const isDirty = description.trim() !== "" || dependencies.length > 0 || pendingImages.length > 0 || executorModel !== "" || validatorModel !== "" || enablePlanningMode; setHasDirtyState(isDirty); }, [description, dependencies, pendingImages, executorModel, validatorModel, enablePlanningMode]); // 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]); // 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(""); setEnablePlanningMode(false); setHasDirtyState(false); onClose(); }, [hasDirtyState, onClose, pendingImages]); const handleSubmit = useCallback(async () => { const trimmedDesc = description.trim(); if (!trimmedDesc || isSubmitting) return; // Planning mode flow: skip task creation, open planning modal instead if (enablePlanningMode && onPlanningMode) { setIsSubmitting(true); try { // Clean up object URLs before closing pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); // Clear form state setPendingImages([]); setDescription(""); setDependencies([]); setExecutorModel(""); setValidatorModel(""); setEnablePlanningMode(false); // Close modal and trigger planning mode onClose(); onPlanningMode(trimmedDesc); } finally { setIsSubmitting(false); } return; } setIsSubmitting(true); try { // Create the base task const task = await onCreateTask({ title: undefined, description: trimmedDesc, column: "triage", dependencies: dependencies.length ? dependencies : 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"); } } // Update task with model settings if specified const executorSlashIdx = executorModel.indexOf("/"); const validatorSlashIdx = validatorModel.indexOf("/"); if (executorModel || validatorModel) { const updates: Parameters[1] = {}; if (executorModel && executorSlashIdx !== -1) { updates.modelProvider = executorModel.slice(0, executorSlashIdx); updates.modelId = executorModel.slice(executorSlashIdx + 1); } if (validatorModel && validatorSlashIdx !== -1) { updates.validatorModelProvider = validatorModel.slice(0, validatorSlashIdx); updates.validatorModelId = validatorModel.slice(validatorSlashIdx + 1); } if (Object.keys(updates).length > 0) { await updateTask(task.id, updates); } } // Clean up pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); setPendingImages([]); setDescription(""); setDependencies([]); setExecutorModel(""); setValidatorModel(""); setEnablePlanningMode(false); 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, enablePlanningMode, isSubmitting, onCreateTask, addToast, onClose, onPlanningMode]); // 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"; }, []); 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 */}