"use client"; import { useEffect, useRef, useState, memo, useCallback, useMemo } from "react"; import { Textarea } from "@kandev/ui/textarea"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { IconPaperclip } from "@tabler/icons-react"; import { Combobox } from "./combobox"; import { processFile, formatBytes, MAX_FILES, MAX_TOTAL_SIZE, type FileAttachment, } from "@/components/task/chat/file-attachment"; import { ContextZone } from "@/components/task/chat/context-items/context-zone"; import type { ContextItem, ImageContextItem, FileAttachmentContextItem } from "@/lib/types/context"; import type { TaskFormInputsHandle } from "@/components/task-create-dialog-types"; import { EnhancePromptButton } from "@/components/enhance-prompt-button"; const CURSOR_POINTER_CLASS = "cursor-pointer"; type RepositoryOption = { value: string; label: string; renderLabel: () => React.ReactNode; }; type RepositorySelectorProps = { options: RepositoryOption[]; value: string; onValueChange: (value: string) => void; disabled: boolean; placeholder: string; searchPlaceholder: string; emptyMessage: string; triggerClassName?: string; }; export const RepositorySelector = memo(function RepositorySelector({ options, value, onValueChange, disabled, placeholder, searchPlaceholder, emptyMessage, triggerClassName, }: RepositorySelectorProps) { return ( ); }); type BranchOption = { value: string; label: string; renderLabel: () => React.ReactNode; }; type BranchSelectorProps = { options: BranchOption[]; value: string; onValueChange: (value: string) => void; disabled: boolean; placeholder: string; searchPlaceholder: string; emptyMessage: string; }; export const BranchSelector = memo(function BranchSelector({ options, value, onValueChange, disabled, placeholder, searchPlaceholder, emptyMessage, }: BranchSelectorProps) { return ( ); }); type AgentSelectorProps = { options: Array<{ value: string; label: string; renderLabel: () => React.ReactNode }>; value: string; onValueChange: (value: string) => void; disabled: boolean; placeholder: string; triggerClassName?: string; }; export const AgentSelector = memo(function AgentSelector({ options, value, onValueChange, disabled, placeholder, triggerClassName, }: AgentSelectorProps) { return ( ); }); type ExecutorSelectorProps = { options: Array<{ value: string; label: string; renderLabel?: () => React.ReactNode }>; value: string; onValueChange: (value: string) => void; disabled: boolean; placeholder: string; triggerClassName?: string; }; export const ExecutorSelector = memo(function ExecutorSelector({ options, value, onValueChange, disabled, placeholder, triggerClassName, }: ExecutorSelectorProps) { return ( ); }); type ExecutorProfileSelectorProps = { options: Array<{ value: string; label: string; renderLabel?: () => React.ReactNode }>; value: string; onValueChange: (value: string) => void; disabled: boolean; placeholder: string; triggerClassName?: string; }; export const ExecutorProfileSelector = memo(function ExecutorProfileSelector({ options, value, onValueChange, disabled, placeholder, triggerClassName, }: ExecutorProfileSelectorProps) { return ( ); }); type InlineTaskNameProps = { value: string; onChange: (value: string) => void; autoFocus?: boolean; }; export const InlineTaskName = memo(function InlineTaskName({ value, onChange, autoFocus, }: InlineTaskNameProps) { const inputRef = useRef(null); useEffect(() => { if (autoFocus && inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } }, [autoFocus]); return ( onChange(e.target.value)} placeholder="task-name" data-testid="task-title-input" size={Math.max(value.length, 9)} className="bg-transparent border-none outline-none focus:ring-0 text-sm font-medium min-w-0 rounded-md px-1.5 py-0.5 -mx-1.5 hover:bg-muted focus:bg-muted transition-colors" /> ); }); // Memoized description input to prevent re-rendering the entire dialog on every keystroke type TaskFormInputsProps = { isSessionMode: boolean; autoFocus?: boolean; initialDescription: string; onDescriptionChange: (hasContent: boolean) => void; onKeyDown: (e: React.KeyboardEvent) => void; descriptionValueRef: React.RefObject; disabled?: boolean; placeholder?: string; onEnhancePrompt?: () => void; isEnhancingPrompt?: boolean; isUtilityConfigured?: boolean; }; function useFileAttachments() { const [attachments, setAttachments] = useState([]); const [isDragging, setIsDragging] = useState(false); const addFiles = useCallback(async (files: File[]) => { const processed: FileAttachment[] = []; for (const file of files) { const attachment = await processFile(file); if (attachment) processed.push(attachment); } if (processed.length === 0) return; setAttachments((prev) => { let nextCount = prev.length; let nextTotalSize = prev.reduce((sum, att) => sum + att.size, 0); const accepted: FileAttachment[] = []; for (const att of processed) { if (nextCount >= MAX_FILES) break; if (nextTotalSize + att.size > MAX_TOTAL_SIZE) break; accepted.push(att); nextCount += 1; nextTotalSize += att.size; } if (nextCount >= MAX_FILES && accepted.length < processed.length) { console.warn(`Maximum ${MAX_FILES} files allowed`); } else if (accepted.length < processed.length) { console.warn(`Total attachment size limit exceeded (max: ${formatBytes(MAX_TOTAL_SIZE)})`); } return accepted.length > 0 ? [...prev, ...accepted] : prev; }); }, []); const handleRemoveAttachment = useCallback((id: string) => { setAttachments((prev) => prev.filter((att) => att.id !== id)); }, []); return { attachments, isDragging, setIsDragging, addFiles, handleRemoveAttachment }; } function useAttachmentHandlers( disabled: boolean | undefined, addFiles: (files: File[]) => Promise, setIsDragging: (v: boolean) => void, ) { const handlePaste = useCallback( (e: React.ClipboardEvent) => { if (disabled) return; const items = e.clipboardData?.items; if (!items) return; const files: File[] = []; for (const item of items) { if (item.kind === "file") { const file = item.getAsFile(); if (file) files.push(file); } } if (files.length > 0) { e.preventDefault(); void addFiles(files); } }, [disabled, addFiles], ); const handleDragOver = useCallback( (e: React.DragEvent) => { if (disabled) return; e.preventDefault(); e.stopPropagation(); setIsDragging(true); }, [disabled, setIsDragging], ); const handleDragLeave = useCallback( (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); const { clientX, clientY } = e; if ( clientX <= rect.left || clientX >= rect.right || clientY <= rect.top || clientY >= rect.bottom ) { setIsDragging(false); } }, [setIsDragging], ); const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); if (disabled) return; const files = Array.from(e.dataTransfer.files).filter((f) => f.size > 0 || f.type !== ""); if (files.length > 0) { void addFiles(files); } }, [disabled, addFiles, setIsDragging], ); return { handlePaste, handleDragOver, handleDragLeave, handleDrop }; } function toContextItems( attachments: FileAttachment[], onRemove: (id: string) => void, ): ContextItem[] { return attachments.map((att) => att.isImage ? ({ kind: "image" as const, id: `image:${att.id}`, label: `Image (${formatBytes(att.size)})`, attachment: att, onRemove: () => onRemove(att.id), } as ImageContextItem) : ({ kind: "file-attachment" as const, id: `file:${att.id}`, label: att.fileName, attachment: att, onRemove: () => onRemove(att.id), } as FileAttachmentContextItem), ); } function AttachButton({ onClick, disabled }: { onClick: () => void; disabled?: boolean }) { return (
Attach files
); } function useDescriptionInput( initialDescription: string, autoFocus: boolean | undefined, descriptionValueRef: React.RefObject, onDescriptionChange: (hasContent: boolean) => void, attachments: FileAttachment[], ) { const [description, setDescription] = useState(initialDescription); const textareaRef = useRef(null); useEffect(() => { const ref = descriptionValueRef as React.MutableRefObject; if (ref) { ref.current = { getValue: () => description, setValue: (v: string) => { setDescription(v); onDescriptionChange(v.trim().length > 0); }, getAttachments: () => attachments, }; } }, [description, attachments, descriptionValueRef, onDescriptionChange]); useEffect(() => { const textarea = textareaRef.current; if (!textarea) return; textarea.style.height = "auto"; textarea.style.height = `${textarea.scrollHeight}px`; }, [description]); useEffect(() => { if (!autoFocus) return; const textarea = textareaRef.current; if (!textarea) return; textarea.focus(); textarea.setSelectionRange(textarea.value.length, textarea.value.length); }, [autoFocus]); const handleDescriptionChange = useCallback( (e: React.ChangeEvent) => { const newValue = e.target.value; const hadContent = description.trim().length > 0; const hasContent = newValue.trim().length > 0; setDescription(newValue); if (hadContent !== hasContent) onDescriptionChange(hasContent); }, [description, onDescriptionChange], ); return { description, textareaRef, handleDescriptionChange }; } export const TaskFormInputs = memo(function TaskFormInputs({ isSessionMode, autoFocus, initialDescription, onDescriptionChange, onKeyDown, descriptionValueRef, disabled, placeholder, onEnhancePrompt, isEnhancingPrompt, isUtilityConfigured, }: TaskFormInputsProps) { const fileInputRef = useRef(null); const { attachments, isDragging, setIsDragging, addFiles, handleRemoveAttachment } = useFileAttachments(); const { handlePaste, handleDragOver, handleDragLeave, handleDrop } = useAttachmentHandlers( disabled, addFiles, setIsDragging, ); const contextItems = useMemo( () => toContextItems(attachments, handleRemoveAttachment), [attachments, handleRemoveAttachment], ); const { description, textareaRef, handleDescriptionChange } = useDescriptionInput( initialDescription, autoFocus, descriptionValueRef, onDescriptionChange, attachments, ); const handleAttachClick = useCallback(() => fileInputRef.current?.click(), []); const handleFileInputChange = useCallback( (e: React.ChangeEvent) => { const files = e.target.files; if (files && files.length > 0) void addFiles(Array.from(files)); e.target.value = ""; }, [addFiles], ); return (
0 ? "ring-0" : ""}`} >