/* eslint-disable max-lines -- groups all create-dialog selector subcomponents; splitting per-selector files is a separate refactor. */ "use client"; import { useEffect, useLayoutEffect, 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 { scoreBranch } from "@/lib/utils/branch-filter"; import { BranchRefreshButton } from "./branch-refresh-button"; 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 { MentionMenu } from "@/components/task/chat/mention-menu"; 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"; import { JiraImportBar } from "@/components/jira/jira-import-bar"; import { LinearImportBar } from "@/components/linear/linear-import-bar"; import { VoiceInputButton } from "@/components/task/chat/voice-input-button"; import type { JiraTicket } from "@/lib/types/jira"; import type { LinearIssue } from "@/lib/types/linear"; import { useTaskCreatePromptMention } from "@/hooks/use-task-create-prompt-mention"; import { cn } from "@/lib/utils"; 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; keywords?: string[]; renderLabel: () => React.ReactNode; }; type BranchSelectorProps = { options: BranchOption[]; value: string; onValueChange: (value: string) => void; disabled: boolean; placeholder: string; searchPlaceholder: string; emptyMessage: string; triggerClassName?: string; onRefresh?: () => void; refreshing?: boolean; fetchedAt?: string; fetchError?: string; loading?: boolean; }; export const BranchSelector = memo(function BranchSelector({ options, value, onValueChange, disabled, placeholder, searchPlaceholder, emptyMessage, triggerClassName, onRefresh, refreshing, fetchedAt, fetchError, loading, }: BranchSelectorProps) { const headerAction = onRefresh ? ( ) : undefined; return ( ); }); type AgentSelectorProps = { options: Array<{ value: string; label: string; renderLabel: () => React.ReactNode }>; value: string; onValueChange: (value: string) => void; disabled: boolean; placeholder: string; triggerClassName?: string; popoverPortal?: boolean; }; export const AgentSelector = memo(function AgentSelector({ options, value, onValueChange, disabled, placeholder, triggerClassName, popoverPortal, }: 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; popoverPortal?: boolean; }; export const ExecutorSelector = memo(function ExecutorSelector({ options, value, onValueChange, disabled, placeholder, triggerClassName, popoverPortal, }: 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; popoverPortal?: boolean; }; export const ExecutorProfileSelector = memo(function ExecutorProfileSelector({ options, value, onValueChange, disabled, placeholder, triggerClassName, popoverPortal, }: 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); const hasFocusedRef = useRef(false); useEffect(() => { if (autoFocus && !hasFocusedRef.current && inputRef.current) { hasFocusedRef.current = true; inputRef.current.focus(); inputRef.current.select(); } }, [autoFocus]); return ( onChange(e.target.value)} placeholder="Task name" data-testid="task-title-input" className="w-full min-w-0 max-w-full border border-input bg-input/20 dark:bg-input/30 text-sm font-medium rounded-md px-3 py-2 placeholder:text-muted-foreground/70 outline-none focus-visible:border-ring 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; jiraImport?: { workspaceId: string | null; disabled?: boolean; onImport: (ticket: JiraTicket) => void; }; linearImport?: { workspaceId: string | null; disabled?: boolean; onImport: (issue: LinearIssue) => void; }; /** * Called after a non-empty voice transcript was inserted into the description * when the user has voice auto-send enabled. The dialog wires this to a * programmatic form submit so dictation can create the task hands-free. */ onVoiceAutoSend?: () => void; }; 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); // Caret offset to restore after a non-typed value mutation (e.g. voice // transcript splice). Consumed inside useLayoutEffect so the cursor lands // before the next paint and the user sees no jump. const pendingCursorRef = 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]); useLayoutEffect(() => { const pos = pendingCursorRef.current; if (pos === null) return; pendingCursorRef.current = null; const textarea = textareaRef.current; if (!textarea) return; textarea.focus(); textarea.setSelectionRange(pos, pos); }, [description]); useEffect(() => { if (!autoFocus) return; const textarea = textareaRef.current; if (!textarea) return; textarea.focus(); textarea.setSelectionRange(textarea.value.length, textarea.value.length); }, [autoFocus]); const setDescriptionValue = useCallback( (newValue: string) => { const hadContent = description.trim().length > 0; const hasContent = newValue.trim().length > 0; setDescription(newValue); if (hadContent !== hasContent) onDescriptionChange(hasContent); }, [description, onDescriptionChange], ); const insertAtCursor = useCallback( (text: string) => { const trimmed = text.trim(); if (!trimmed) return; const textarea = textareaRef.current; const start = textarea?.selectionStart ?? description.length; const end = textarea?.selectionEnd ?? description.length; const charBefore = start > 0 ? description.charAt(start - 1) : ""; const needsLeadingSpace = charBefore !== "" && !/\s/.test(charBefore); const insert = needsLeadingSpace ? ` ${trimmed}` : trimmed; const next = description.slice(0, start) + insert + description.slice(end); pendingCursorRef.current = start + insert.length; setDescriptionValue(next); }, [description, setDescriptionValue], ); return { description, textareaRef, setDescriptionValue, insertAtCursor }; } type FormInputsToolbarProps = { onAttach: () => void; disabled?: boolean; onEnhancePrompt?: () => void; isEnhancingPrompt?: boolean; isUtilityConfigured?: boolean; jiraImport?: TaskFormInputsProps["jiraImport"]; linearImport?: TaskFormInputsProps["linearImport"]; voice?: { onTranscript: (text: string) => void; onAutoSend?: () => void; }; }; function FormInputsToolbar({ onAttach, disabled, onEnhancePrompt, isEnhancingPrompt, isUtilityConfigured, jiraImport, linearImport, voice, }: FormInputsToolbarProps) { return (
{onEnhancePrompt && ( )} {jiraImport && ( )} {linearImport && ( )} {voice && (
)}
); } function PromptMentionPopover({ mention, }: { mention: ReturnType; }) { return ( ); } function useTextareaHandlers( mention: ReturnType, onKeyDown: TaskFormInputsProps["onKeyDown"], ) { const { handleChange: mentionHandleChange, handleKeyDown: mentionHandleKeyDown } = mention; const handleChange = useCallback( (e: React.ChangeEvent) => mentionHandleChange(e.target.value), [mentionHandleChange], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { mentionHandleKeyDown(e); if (e.defaultPrevented) return; onKeyDown?.(e); }, [mentionHandleKeyDown, onKeyDown], ); return { handleChange, handleKeyDown }; } function useFileInputClick(addFiles: (files: File[]) => Promise | void) { const fileInputRef = useRef(null); 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 { fileInputRef, handleAttachClick, handleFileInputChange }; } function HiddenFileInput({ inputRef, onChange, }: { inputRef: React.RefObject; onChange: (e: React.ChangeEvent) => void; }) { return ( ); } function DraggingOverlay({ isDragging }: { isDragging: boolean }) { if (!isDragging) return null; return (
Drop files here
); } export const TaskFormInputs = memo(function TaskFormInputs({ isSessionMode, autoFocus, initialDescription, onDescriptionChange, onKeyDown, descriptionValueRef, disabled, placeholder, onEnhancePrompt, isEnhancingPrompt, isUtilityConfigured, jiraImport, linearImport, onVoiceAutoSend, }: TaskFormInputsProps) { 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, setDescriptionValue, insertAtCursor } = useDescriptionInput( initialDescription, autoFocus, descriptionValueRef, onDescriptionChange, attachments, ); const mention = useTaskCreatePromptMention({ textareaRef, value: description, onChange: setDescriptionValue, }); const { handleChange, handleKeyDown } = useTextareaHandlers(mention, onKeyDown); const { fileInputRef, handleAttachClick, handleFileInputChange } = useFileInputClick(addFiles); const voiceBinding = useMemo( () => ({ onTranscript: insertAtCursor, onAutoSend: onVoiceAutoSend }), [insertAtCursor, onVoiceAutoSend], ); return (
0 ? "ring-0" : ""}`} >