import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent } from 'react'; import { Send } from 'lucide-react'; import { toast } from 'sonner'; import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; import { flattenToMessage, inferLanguage, looksBinary, MAX_FILE_SIZE_BYTES, PASTE_INLINE_MAX_LINES, type Attachment, } from '@/lib/attachments'; import { AttachmentChip } from '@/components/AttachmentChip'; import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal'; import { FileMentionPopover } from '@/components/FileMentionPopover'; import { DropOverlay } from '@/components/DropOverlay'; import { AgentPicker } from '@/components/AgentPicker'; import { api } from '@/api/client'; import { sessionEvents } from '@/hooks/sessionEvents'; import { useViewport } from '@/hooks/useViewport'; const MAX_ATTACHMENTS = 10; interface Props { disabled?: boolean; projectId: string; // Batch 9: optional so callers that pre-date the agent picker still compile. // When omitted, the toolbar row is hidden entirely. agentId?: string | null; onAgentChange?: (agentId: string | null) => void | Promise; onSend: (content: string) => void | Promise; onForceSend?: (content: string) => void | Promise; } export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend, onForceSend }: Props) { const { isMobile } = useViewport(); const [value, setValue] = useState(''); const [busy, setBusy] = useState(false); const [attachments, setAttachments] = useState([]); const [previewAttachment, setPreviewAttachment] = useState(null); const [isDraggingOver, setIsDraggingOver] = useState(false); const dropRootRef = useRef(null); const pasteCounterRef = useRef(0); const [mentionState, setMentionState] = useState<{ open: boolean; query: string; atIdx: number; anchorRect: { top: number; left: number }; } | null>(null); const [fileIndex, setFileIndex] = useState(null); const textareaRef = useRef(null); function addAttachment(a: Attachment) { setAttachments(prev => { if (prev.length >= MAX_ATTACHMENTS) { toast.error(`Max ${MAX_ATTACHMENTS} attachments per message`); return prev; } return [...prev, a]; }); } const addAttachmentRef = useRef(addAttachment); addAttachmentRef.current = addAttachment; useEffect(() => { return sessionEvents.subscribe((event) => { if (event.type !== 'attach_chat_file') return; addAttachmentRef.current({ id: crypto.randomUUID(), ...event.attachment, }); }); }, []); function removeAttachment(id: string) { setAttachments(prev => prev.filter(a => a.id !== id)); } async function submit() { const text = value.trim(); if (!text && attachments.length === 0) return; if (disabled || busy) return; setBusy(true); try { const body = flattenToMessage(attachments, text); await onSend(body); setValue(''); setAttachments([]); } catch (err) { toast.error(err instanceof Error ? err.message : 'failed to send'); } finally { setBusy(false); } } function getCaretCoords(textarea: HTMLTextAreaElement): { top: number; left: number } { const mirror = document.createElement('div'); const style = window.getComputedStyle(textarea); const properties = [ 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'letterSpacing', 'lineHeight', 'textTransform', 'wordSpacing', 'textIndent', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'boxSizing', 'whiteSpace', 'overflowWrap', ] as const; mirror.style.position = 'absolute'; mirror.style.visibility = 'hidden'; mirror.style.overflow = 'hidden'; mirror.style.width = style.width; for (const prop of properties) { mirror.style[prop] = style[prop]; } mirror.style.whiteSpace = 'pre-wrap'; mirror.style.overflowWrap = 'break-word'; const textBefore = textarea.value.slice(0, textarea.selectionStart); mirror.textContent = textBefore; const span = document.createElement('span'); span.textContent = '​'; // zero-width space mirror.appendChild(span); document.body.appendChild(mirror); const taRect = textarea.getBoundingClientRect(); const spanRect = span.getBoundingClientRect(); const mirrorRect = mirror.getBoundingClientRect(); const top = taRect.top + (spanRect.top - mirrorRect.top) - textarea.scrollTop + span.offsetHeight; const left = taRect.left + (spanRect.left - mirrorRect.left); document.body.removeChild(mirror); return { top, left }; } function handleChange(e: React.ChangeEvent) { const newValue = e.target.value; setValue(newValue); const ta = e.target; const pos = ta.selectionStart; // Check for @ trigger if (pos > 0 && newValue[pos - 1] === '@') { const charBefore = pos >= 2 ? newValue[pos - 2] : null; if (charBefore === null || charBefore === ' ' || charBefore === '\n') { const coords = getCaretCoords(ta); setMentionState({ open: true, query: '', atIdx: pos - 1, anchorRect: coords }); if (!fileIndex) { api.projects.files(projectId).then(r => setFileIndex(r.files)).catch(() => {}); } return; } } // Update query if popover is open — use stored atIdx if (mentionState?.open) { const { atIdx } = mentionState; if (atIdx < pos && newValue[atIdx] === '@') { const query = newValue.slice(atIdx + 1, pos); setMentionState(prev => prev ? { ...prev, query } : null); } else { setMentionState(null); } } } async function handleMentionSelect(path: string) { const atIdx = mentionState?.atIdx ?? -1; const ta = textareaRef.current; const caretPos = ta?.selectionStart ?? value.length; setMentionState(null); try { const result = await api.projects.viewFile(projectId, path); if (atIdx >= 0) { const cleaned = value.slice(0, atIdx) + value.slice(caretPos); setValue(cleaned); if (ta) { requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = atIdx; ta.focus(); }); } } addAttachment({ id: crypto.randomUUID(), kind: 'file', filename: path, language: inferLanguage(path), content: result.content, source: '@', }); } catch { toast.error('Failed to load file'); } } const closeMention = useCallback(() => setMentionState(null), []); // ---- Drag & drop (F1 + F3 + F4) ---------------------------------------- // The drop zone is the outer ChatInput container (ref'd as dropRootRef). // onDragLeave only clears the highlight when the cursor leaves the // container, not when it crosses into a child element. async function processDroppedFile(file: File) { // Size gate if (file.size > MAX_FILE_SIZE_BYTES) { const mb = (file.size / (1024 * 1024)).toFixed(1); toast.error(`File ${file.name} is too large (${mb} MB). Limit is 5 MB.`); return; } // Read once as ArrayBuffer so we can do byte-level binary detection // before deciding whether to decode as text. let buf: ArrayBuffer; try { buf = await file.arrayBuffer(); } catch (err) { toast.error(`Failed to read ${file.name}: ${err instanceof Error ? err.message : String(err)}`); return; } if (looksBinary(buf)) { toast.error(`${file.name} appears to be binary.`); return; } const text = new TextDecoder('utf-8', { fatal: false }).decode(buf); addAttachment({ id: crypto.randomUUID(), kind: 'file', filename: file.name, language: inferLanguage(file.name), content: text, source: 'drop', }); } function isFolderItem(item: DataTransferItem | undefined): boolean { if (!item) return false; // webkitGetAsEntry is non-standard but supported in Chromium + Safari. // If unavailable, we conservatively treat the entry as a file. const entry = typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null; if (entry && entry.isDirectory) return true; // Heuristic fallback: folders dragged from Finder have type === '' and // a 0-byte File. The empty-type alone isn't reliable for files (some // plaintext drops also lack a type), so we only flag when the entry // explicitly says directory. return false; } async function handleDroppedItems(dt: DataTransfer) { // Snapshot items first because reading files inside the loop can // detach the DataTransfer between awaits. const itemsArray: { file: File | null; isFolder: boolean }[] = []; if (dt.items && dt.items.length > 0) { for (let i = 0; i < dt.items.length; i++) { const it = dt.items[i]; if (!it || it.kind !== 'file') continue; const folder = isFolderItem(it); const file = folder ? null : it.getAsFile(); itemsArray.push({ file, isFolder: folder }); } } else { for (let i = 0; i < dt.files.length; i++) { const f = dt.files[i]; if (f) itemsArray.push({ file: f, isFolder: false }); } } let remainingSlots = MAX_ATTACHMENTS - attachments.length; let folderRejected = false; for (const { file, isFolder } of itemsArray) { if (isFolder) { if (!folderRejected) { toast.error('Folders are not supported'); folderRejected = true; } continue; } if (!file) continue; if (remainingSlots <= 0) { toast.error(`Attachment limit reached (${MAX_ATTACHMENTS}).`); return; } await processDroppedFile(file); remainingSlots -= 1; } } function onDragEnter(e: DragEvent) { if (disabled || busy) return; e.preventDefault(); setIsDraggingOver(true); } function onDragOver(e: DragEvent) { if (disabled || busy) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; } function onDragLeave(e: DragEvent) { // Only clear when the cursor actually leaves the root container. // relatedTarget is the element being entered; if it's inside the root, // ignore — we're just crossing into a child. const root = dropRootRef.current; if (!root) return; const related = e.relatedTarget as Node | null; if (related && root.contains(related)) return; setIsDraggingOver(false); } function onDrop(e: DragEvent) { e.preventDefault(); setIsDraggingOver(false); if (disabled || busy) return; void handleDroppedItems(e.dataTransfer); } // ---- end Drag & drop ----------------------------------------------------- // ---- Paste-as-attachment (F2) ------------------------------------------- // Pasting >PASTE_INLINE_MAX_LINES lines of text becomes a chip rather than // inline content. Image pastes are rejected with a toast. If both text and // image are present (e.g. screenshot tool that sets both), prefer text. function onPaste(e: React.ClipboardEvent) { const cd = e.clipboardData; if (!cd) return; const text = cd.getData('text/plain'); const hasImage = Array.from(cd.items ?? []).some((it) => it.type.startsWith('image/'), ); if (text) { const lineCount = text.split('\n').length; if (lineCount > PASTE_INLINE_MAX_LINES) { e.preventDefault(); pasteCounterRef.current += 1; addAttachment({ id: crypto.randomUUID(), kind: 'paste', filename: `pasted-${pasteCounterRef.current}.txt`, language: 'plaintext', content: text, source: 'paste', }); } // <= threshold: let default paste insert inline. return; } if (hasImage) { e.preventDefault(); toast.error('Image paste is not supported. Drop a file or paste text.'); } } // ---- end Paste-as-attachment -------------------------------------------- function onKeyDown(e: KeyboardEvent) { if (mentionState?.open) return; // IME safety: never act on Enter while an IME composition is in flight // (CJK input methods commit composition via Enter). Without this, the // first Enter of a Japanese/Chinese/Korean composition would submit // instead of finalizing the candidate. if (e.nativeEvent.isComposing) return; if (e.key === 'Enter' && e.shiftKey && (e.metaKey || e.ctrlKey) && onForceSend) { e.preventDefault(); void forceSubmit(); return; } if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); void submit(); return; } // Bare Enter: sends on desktop, inserts a newline on mobile (per spec — // send is via the dedicated button on touch devices). if (e.key === 'Enter' && !e.shiftKey && !isMobile) { e.preventDefault(); void submit(); } } async function forceSubmit() { const text = value.trim(); if (!text || !onForceSend) return; if (busy) return; setBusy(true); try { const body = flattenToMessage(attachments, text); await onForceSend(body); setValue(''); setAttachments([]); } catch (err) { toast.error(err instanceof Error ? err.message : 'force send failed'); } finally { setBusy(false); } } return (
{attachments.length > 0 && (
{attachments.map(a => ( ))}
)} {/* Batch 9 toolbar — agent picker. Sits above the input row so it doesn't compete with the send button for vertical alignment. When Batch 7 lands, ModelPicker and the + button join this row. */} {onAgentChange && (
)}