import { useState, useRef, useEffect, useCallback } from 'react'; import { Send, Square, Paperclip, X, FileText, Eye, EyeOff, Reply } from 'lucide-react'; import { useT } from '../hooks/useLocale'; import { useSendShortcut } from '../hooks/useSendShortcut'; import { SlashCommandMenu } from './SlashCommands'; import { shouldShowSlashMenu } from '../lib/slashUtils'; import { LazyMarkdown } from './LazyMarkdown'; interface FileAttachment { id: string; file: File; base64: string; // raw base64 (no data: prefix) mimeType: string; preview?: string; // data url thumbnail for images } export interface ReplyContext { preview: string; } interface Props { onSend: (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => void; onNewSession?: () => Promise; onAbort: () => void; isGenerating: boolean; disabled: boolean; sessionKey?: string; replyTo?: ReplyContext | null; onCancelReply?: () => void; } const MAX_BASE64_CHARS = 300 * 1024; // ~225KB real, well under 512KB WS limit (JSON overhead + base64 bloat) const MAX_IMAGE_PIXELS = 1280; // Max dimension for resize function fileToBase64(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const dataUrl = reader.result as string; const base64 = dataUrl.split(',')[1] || ''; resolve(base64); }; reader.onerror = reject; reader.readAsDataURL(file); }); } function compressImage(file: File, maxBase64Chars: number): Promise<{ base64: string; mimeType: string }> { return new Promise((resolve, reject) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { URL.revokeObjectURL(url); let { width, height } = img; // Downscale if needed if (width > MAX_IMAGE_PIXELS || height > MAX_IMAGE_PIXELS) { const ratio = Math.min(MAX_IMAGE_PIXELS / width, MAX_IMAGE_PIXELS / height); width = Math.round(width * ratio); height = Math.round(height * ratio); } const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d')!; ctx.drawImage(img, 0, 0, width, height); // Try JPEG at decreasing quality until base64 length is under limit for (let q = 0.85; q >= 0.2; q -= 0.05) { const dataUrl = canvas.toDataURL('image/jpeg', q); const b64 = dataUrl.split(',')[1] || ''; if (b64.length <= maxBase64Chars) { return resolve({ base64: b64, mimeType: 'image/jpeg' }); } } // Last resort: further downscale const scale = 0.5; canvas.width = Math.round(width * scale); canvas.height = Math.round(height * scale); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); const dataUrl = canvas.toDataURL('image/jpeg', 0.3); resolve({ base64: dataUrl.split(',')[1] || '', mimeType: 'image/jpeg' }); }; img.onerror = reject; img.src = url; }); } function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes}B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`; return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; } export function ChatInput({ onSend, onNewSession, onAbort, isGenerating, disabled, sessionKey, replyTo, onCancelReply }: Props) { const t = useT(); const { sendOnEnter, toggle: toggleSendShortcut } = useSendShortcut(); const [text, setText] = useState(''); const [files, setFiles] = useState([]); const [isDragOver, setIsDragOver] = useState(false); const [showPreview, setShowPreview] = useState(() => localStorage.getItem('pinchchat-md-preview') === '1'); const [isComposing, setIsComposing] = useState(false); const [showSlash, setShowSlash] = useState(false); const textareaRef = useRef(null); const fileInputRef = useRef(null); // Per-session draft storage const draftsRef = useRef>(new Map()); const prevSessionRef = useRef(sessionKey); // Save draft to previous session and restore draft for new session useEffect(() => { const prev = prevSessionRef.current; // Save current text as draft for the previous session if (prev && prev !== sessionKey) { const currentText = textareaRef.current?.value ?? text; if (currentText.trim()) { draftsRef.current.set(prev, currentText); } else { draftsRef.current.delete(prev); } } // Restore draft for the new session const draft = sessionKey ? draftsRef.current.get(sessionKey) ?? '' : ''; setText(draft); // eslint-disable-line react-hooks/set-state-in-effect -- intentional: restore draft on session switch prevSessionRef.current = sessionKey; }, [sessionKey]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (textareaRef.current) { textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 200) + 'px'; } }, [text]); // Auto-focus textarea when session changes or connection becomes active useEffect(() => { if (!disabled && textareaRef.current) { // Small delay to let the DOM settle after session switch const timer = setTimeout(() => textareaRef.current?.focus(), 50); return () => clearTimeout(timer); } }, [sessionKey, disabled]); const addFiles = useCallback(async (fileList: FileList | File[]) => { const newFiles: FileAttachment[] = []; for (const file of Array.from(fileList)) { if (file.size > 20 * 1024 * 1024) continue; // 20MB max const isImage = file.type.startsWith('image/'); let base64: string; let mimeType: string; if (isImage) { // Compress images to fit WS payload limit const compressed = await compressImage(file, MAX_BASE64_CHARS); base64 = compressed.base64; mimeType = compressed.mimeType; } else { base64 = await fileToBase64(file); mimeType = file.type || 'application/octet-stream'; } newFiles.push({ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, file, base64, mimeType, preview: isImage ? `data:${mimeType};base64,${base64}` : undefined, }); } setFiles(prev => [...prev, ...newFiles]); }, []); const removeFile = useCallback((id: string) => { setFiles(prev => prev.filter(f => f.id !== id)); }, []); const handleSubmit = () => { const trimmed = text.trim(); if ((!trimmed && files.length === 0) || disabled) return; if ((trimmed === '/new' || trimmed.startsWith('/new ')) && onNewSession) { void onNewSession(); setText(''); setFiles([]); setShowSlash(false); onCancelReply?.(); if (sessionKey) draftsRef.current.delete(sessionKey); return; } const attachments = files.length > 0 ? files.map(f => ({ mimeType: f.mimeType, fileName: f.file.name, content: f.base64, })) : undefined; // Prepend quote if replying let finalText = trimmed || ' '; if (replyTo?.preview) { const quoteLine = replyTo.preview.split('\n')[0].slice(0, 80); finalText = `> ${quoteLine}\n\n${finalText}`; } onSend(finalText, attachments); setText(''); setFiles([]); setShowSlash(false); onCancelReply?.(); // Clear draft for this session after sending if (sessionKey) draftsRef.current.delete(sessionKey); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { // Prevent sending when IME is composing (e.g., Chinese/Japanese input) // Check both React state and native event property — on some browsers // compositionend fires before keydown, making isComposing stale if (isComposing || e.nativeEvent.isComposing || e.keyCode === 229) return; if (sendOnEnter) { // Enter sends, Shift+Enter for newline if (!e.shiftKey && !e.ctrlKey && !e.metaKey) { e.preventDefault(); handleSubmit(); } } else { // Ctrl+Enter (or Cmd+Enter on Mac) sends, Enter for newline if (e.ctrlKey || e.metaKey) { e.preventDefault(); handleSubmit(); } } } }; const handlePaste = useCallback((e: React.ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; const pastedFiles: File[] = []; for (const item of Array.from(items)) { if (item.kind === 'file') { const file = item.getAsFile(); if (file) pastedFiles.push(file); } } if (pastedFiles.length > 0) { e.preventDefault(); addFiles(pastedFiles); } }, [addFiles]); // Drag & drop handlers on the wrapper const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(true); }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); }, []); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); if (e.dataTransfer.files.length > 0) { addFiles(e.dataTransfer.files); } }, [addFiles]); return (
{ // Commands without args (no trailing space) should submit immediately if (!cmd.endsWith(' ')) { setText(cmd); setShowSlash(false); // Submit directly — need to call onSend/onNewSession inline since setState is async if ((cmd === '/new') && onNewSession) { void onNewSession(); setText(''); setFiles([]); onCancelReply?.(); if (sessionKey) draftsRef.current.delete(sessionKey); } else { // For other no-arg commands (e.g. /status, /help), send as text const finalText = replyTo?.preview ? `> ${replyTo.preview.split('\n')[0].slice(0, 80)}\n\n${cmd}` : cmd; onSend(finalText); setText(''); setFiles([]); onCancelReply?.(); if (sessionKey) draftsRef.current.delete(sessionKey); } } else { setText(cmd); setShowSlash(shouldShowSlashMenu(cmd)); textareaRef.current?.focus(); } }} onClose={() => setShowSlash(false)} /> {/* Reply context banner */} {replyTo && (
{replyTo.preview || '…'}
)} {/* File previews */} {files.length > 0 && (
{files.map(f => (
{f.preview ? ( ) : ( )}
{f.file.name}
{formatSize(f.file.size)}
))}
)} {/* Markdown preview */} {showPreview && text.trim() && (
{text}
)}
{/* File picker button */} {/* Markdown preview toggle — hidden on mobile */} { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }} accept="image/*,.pdf,.txt,.md,.json,.csv,.log,.py,.js,.ts,.tsx,.jsx,.html,.css,.yaml,.yml,.xml,.sql,.sh,.env,.toml" />