import React, { useState, useRef, useEffect, useCallback, useMemo } from "react"; // Web Speech API types interface SpeechRecognitionEvent extends Event { results: SpeechRecognitionResultList; resultIndex: number; } interface SpeechRecognitionResultList { length: number; item(index: number): SpeechRecognitionResult; [index: number]: SpeechRecognitionResult; } interface SpeechRecognitionResult { isFinal: boolean; length: number; item(index: number): SpeechRecognitionAlternative; [index: number]: SpeechRecognitionAlternative; } interface SpeechRecognitionAlternative { transcript: string; confidence: number; } interface SpeechRecognition extends EventTarget { continuous: boolean; interimResults: boolean; lang: string; onresult: ((event: SpeechRecognitionEvent) => void) | null; onerror: ((event: Event & { error: string }) => void) | null; onend: (() => void) | null; start(): void; stop(): void; abort(): void; } declare global { interface Window { SpeechRecognition: new () => SpeechRecognition; webkitSpeechRecognition: new () => SpeechRecognition; } } interface MessageInputProps { onSend: (message: string) => Promise; disabled?: boolean; autoFocus?: boolean; onFocus?: () => void; injectedText?: string; onClearInjectedText?: () => void; /** If set, persist draft message to localStorage under this key */ persistKey?: string; } const PERSIST_KEY_PREFIX = "shelley_draft_"; function MessageInput({ onSend, disabled = false, autoFocus = false, onFocus, injectedText, onClearInjectedText, persistKey, }: MessageInputProps) { const [message, setMessage] = useState(() => { // Load persisted draft if persistKey is set if (persistKey) { return localStorage.getItem(PERSIST_KEY_PREFIX + persistKey) || ""; } return ""; }); const [submitting, setSubmitting] = useState(false); const [uploadsInProgress, setUploadsInProgress] = useState(0); const [dragCounter, setDragCounter] = useState(0); const [isListening, setIsListening] = useState(false); const [isSmallScreen, setIsSmallScreen] = useState(() => { if (typeof window === "undefined") return false; return window.innerWidth < 480; }); const textareaRef = useRef(null); const fileInputRef = useRef(null); const recognitionRef = useRef(null); // Track the base text (before speech recognition started) and finalized speech text const baseTextRef = useRef(""); const finalizedTextRef = useRef(""); // Check if speech recognition is available const speechRecognitionAvailable = typeof window !== "undefined" && (window.SpeechRecognition || window.webkitSpeechRecognition); // Responsive placeholder text const placeholderText = useMemo( () => (isSmallScreen ? "Message..." : "Message, paste image, or attach file..."), [isSmallScreen], ); // Track screen size for responsive placeholder useEffect(() => { const handleResize = () => { setIsSmallScreen(window.innerWidth < 480); }; window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, []); const stopListening = useCallback(() => { if (recognitionRef.current) { recognitionRef.current.stop(); recognitionRef.current = null; } setIsListening(false); }, []); const startListening = useCallback(() => { if (!speechRecognitionAvailable) return; const SpeechRecognitionClass = window.SpeechRecognition || window.webkitSpeechRecognition; const recognition = new SpeechRecognitionClass(); recognition.continuous = true; recognition.interimResults = true; recognition.lang = navigator.language || "en-US"; // Capture current message as base text setMessage((current) => { baseTextRef.current = current; finalizedTextRef.current = ""; return current; }); recognition.onresult = (event: SpeechRecognitionEvent) => { let finalTranscript = ""; let interimTranscript = ""; for (let i = event.resultIndex; i < event.results.length; i++) { const transcript = event.results[i][0].transcript; if (event.results[i].isFinal) { finalTranscript += transcript; } else { interimTranscript += transcript; } } // Accumulate finalized text if (finalTranscript) { finalizedTextRef.current += finalTranscript; } // Build the full message: base + finalized + interim const base = baseTextRef.current; const needsSpace = base.length > 0 && !/\s$/.test(base); const spacer = needsSpace ? " " : ""; const fullText = base + spacer + finalizedTextRef.current + interimTranscript; setMessage(fullText); }; recognition.onerror = (event) => { console.error("Speech recognition error:", event.error); stopListening(); }; recognition.onend = () => { setIsListening(false); recognitionRef.current = null; }; recognitionRef.current = recognition; recognition.start(); setIsListening(true); }, [speechRecognitionAvailable, stopListening]); const toggleListening = useCallback(() => { if (isListening) { stopListening(); } else { startListening(); } }, [isListening, startListening, stopListening]); // Cleanup on unmount useEffect(() => { return () => { if (recognitionRef.current) { recognitionRef.current.abort(); } }; }, []); const uploadFile = async (file: File) => { // Add a loading indicator at the end of the current message const loadingText = `[uploading ${file.name}...]`; setMessage((prev) => (prev ? prev + " " : "") + loadingText); setUploadsInProgress((prev) => prev + 1); try { const formData = new FormData(); formData.append("file", file); const response = await fetch("/api/upload", { method: "POST", headers: { "X-Shelley-Request": "1" }, body: formData, }); if (!response.ok) { throw new Error(`Upload failed: ${response.statusText}`); } const data = await response.json(); // Replace the loading placeholder with the actual file path setMessage((currentMessage) => currentMessage.replace(loadingText, `[${data.path}]`)); } catch (error) { console.error("Failed to upload file:", error); // Replace loading indicator with error message const errorText = `[upload failed: ${error instanceof Error ? error.message : "unknown error"}]`; setMessage((currentMessage) => currentMessage.replace(loadingText, errorText)); } finally { setUploadsInProgress((prev) => prev - 1); } }; const handlePaste = (event: React.ClipboardEvent) => { // Check clipboard items (works on both desktop and mobile) // Mobile browsers often don't populate clipboardData.files, but items works const items = event.clipboardData?.items; if (items) { for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.kind === "file") { const file = item.getAsFile(); if (file) { event.preventDefault(); // Fire and forget - uploadFile handles state updates internally. uploadFile(file); // Restore focus after React updates. We use setTimeout(0) to ensure // the focus happens after React's state update commits to the DOM. // The timeout must be longer than 0 to reliably work across browsers. setTimeout(() => { textareaRef.current?.focus(); }, 10); return; } } } } }; const handleDragOver = (event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); }; const handleDragEnter = (event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); setDragCounter((prev) => prev + 1); }; const handleDragLeave = (event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); setDragCounter((prev) => prev - 1); }; const handleDrop = async (event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); setDragCounter(0); if (event.dataTransfer && event.dataTransfer.files.length > 0) { // Process all dropped files for (let i = 0; i < event.dataTransfer.files.length; i++) { const file = event.dataTransfer.files[i]; await uploadFile(file); } } }; const handleAttachClick = () => { fileInputRef.current?.click(); }; const handleFileSelect = async (event: React.ChangeEvent) => { const files = event.target.files; if (!files || files.length === 0) return; for (let i = 0; i < files.length; i++) { const file = files[i]; await uploadFile(file); } // Reset input so same file can be selected again event.target.value = ""; }; // Auto-insert injected text (diff comments) directly into the textarea useEffect(() => { if (injectedText) { setMessage((prev) => { const needsNewline = prev.length > 0 && !prev.endsWith("\n"); return prev + (needsNewline ? "\n\n" : "") + injectedText; }); onClearInjectedText?.(); // Focus the textarea after inserting setTimeout(() => textareaRef.current?.focus(), 0); } }, [injectedText, onClearInjectedText]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (message.trim() && !disabled && !submitting && uploadsInProgress === 0) { // Stop listening if we were recording if (isListening) { stopListening(); } const messageToSend = message; setSubmitting(true); try { await onSend(messageToSend); // Only clear on success setMessage(""); // Clear persisted draft on successful send if (persistKey) { localStorage.removeItem(PERSIST_KEY_PREFIX + persistKey); } } catch { // Keep the message on error so user can retry } finally { setSubmitting(false); } } }; const handleKeyDown = (e: React.KeyboardEvent) => { // Don't submit while IME is composing (e.g., converting Japanese hiragana to kanji) if (e.nativeEvent.isComposing) { return; } if (e.key === "Enter" && !e.shiftKey) { // On mobile, let Enter create newlines since there's a send button // I'm not convinced the divergence from desktop is the correct answer, // but we can try it and see how it feels. const isMobile = "ontouchstart" in window; if (isMobile) { return; } e.preventDefault(); handleSubmit(e); } }; const adjustTextareaHeight = () => { if (textareaRef.current) { textareaRef.current.style.height = "auto"; const scrollHeight = textareaRef.current.scrollHeight; const maxHeight = 200; // Maximum height in pixels textareaRef.current.style.height = `${Math.min(scrollHeight, maxHeight)}px`; } }; useEffect(() => { adjustTextareaHeight(); }, [message]); // Persist draft to localStorage when persistKey is set useEffect(() => { if (persistKey) { if (message) { localStorage.setItem(PERSIST_KEY_PREFIX + persistKey, message); } else { localStorage.removeItem(PERSIST_KEY_PREFIX + persistKey); } } }, [message, persistKey]); useEffect(() => { if (autoFocus && textareaRef.current) { // Use setTimeout to ensure the component is fully rendered setTimeout(() => { textareaRef.current?.focus(); }, 0); } }, [autoFocus]); // Handle virtual keyboard appearance on mobile (especially Android Firefox) // The visualViewport API lets us detect when the keyboard shrinks the viewport useEffect(() => { if (typeof window === "undefined" || !window.visualViewport) { return; } const handleViewportResize = () => { // Only scroll if our textarea is focused (keyboard is for us) if (document.activeElement === textareaRef.current) { // Small delay to let the viewport settle after resize requestAnimationFrame(() => { textareaRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); }); } }; window.visualViewport.addEventListener("resize", handleViewportResize); return () => { window.visualViewport?.removeEventListener("resize", handleViewportResize); }; }, []); const isDisabled = disabled || uploadsInProgress > 0; const canSubmit = message.trim() && !isDisabled && !submitting; const isDraggingOver = dragCounter > 0; // Check if user is typing a shell command (starts with !) const isShellMode = message.trimStart().startsWith("!"); // Note: injectedText is auto-inserted via useEffect, no manual UI needed return (
{isDraggingOver && (
Drop files here
)}
{isShellMode && (
)}