import { useCallback, useEffect, useRef, useState, 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, type Attachment } from '@/lib/attachments'; import { AttachmentChip } from '@/components/AttachmentChip'; import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal'; import { FileMentionPopover } from '@/components/FileMentionPopover'; import { api } from '@/api/client'; import { sessionEvents } from '@/hooks/sessionEvents'; import { useViewport } from '@/hooks/useViewport'; interface Props { disabled?: boolean; projectId: string; onSend: (content: string) => void | Promise; onForceSend?: (content: string) => void | Promise; } export function ChatInput({ disabled, projectId, 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 [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 >= 10) { toast.error('Max 10 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), []); 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 => ( ))}
)}