1import React, { useState, useRef, useEffect, useCallback, useMemo } from "react";
2
3// Web Speech API types
4interface SpeechRecognitionEvent extends Event {
5 results: SpeechRecognitionResultList;
6 resultIndex: number;
7}
8
9interface SpeechRecognitionResultList {
10 length: number;
11 item(index: number): SpeechRecognitionResult;
12 [index: number]: SpeechRecognitionResult;
13}
14
15interface SpeechRecognitionResult {
16 isFinal: boolean;
17 length: number;
18 item(index: number): SpeechRecognitionAlternative;
19 [index: number]: SpeechRecognitionAlternative;
20}
21
22interface SpeechRecognitionAlternative {
23 transcript: string;
24 confidence: number;
25}
26
27interface SpeechRecognition extends EventTarget {
28 continuous: boolean;
29 interimResults: boolean;
30 lang: string;
31 onresult: ((event: SpeechRecognitionEvent) => void) | null;
32 onerror: ((event: Event & { error: string }) => void) | null;
33 onend: (() => void) | null;
34 start(): void;
35 stop(): void;
36 abort(): void;
37}
38
39declare global {
40 interface Window {
41 SpeechRecognition: new () => SpeechRecognition;
42 webkitSpeechRecognition: new () => SpeechRecognition;
43 }
44}
45
46interface MessageInputProps {
47 onSend: (message: string) => Promise<void>;
48 disabled?: boolean;
49 autoFocus?: boolean;
50 onFocus?: () => void;
51 injectedText?: string;
52 onClearInjectedText?: () => void;
53 /** If set, persist draft message to localStorage under this key */
54 persistKey?: string;
55}
56
57const PERSIST_KEY_PREFIX = "shelley_draft_";
58
59function MessageInput({
60 onSend,
61 disabled = false,
62 autoFocus = false,
63 onFocus,
64 injectedText,
65 onClearInjectedText,
66 persistKey,
67}: MessageInputProps) {
68 const [message, setMessage] = useState(() => {
69 // Load persisted draft if persistKey is set
70 if (persistKey) {
71 return localStorage.getItem(PERSIST_KEY_PREFIX + persistKey) || "";
72 }
73 return "";
74 });
75 const [submitting, setSubmitting] = useState(false);
76 const [uploadsInProgress, setUploadsInProgress] = useState(0);
77 const [dragCounter, setDragCounter] = useState(0);
78 const [isListening, setIsListening] = useState(false);
79 const [isSmallScreen, setIsSmallScreen] = useState(() => {
80 if (typeof window === "undefined") return false;
81 return window.innerWidth < 480;
82 });
83 const textareaRef = useRef<HTMLTextAreaElement>(null);
84 const fileInputRef = useRef<HTMLInputElement>(null);
85 const recognitionRef = useRef<SpeechRecognition | null>(null);
86 // Track the base text (before speech recognition started) and finalized speech text
87 const baseTextRef = useRef<string>("");
88 const finalizedTextRef = useRef<string>("");
89
90 // Check if speech recognition is available
91 const speechRecognitionAvailable =
92 typeof window !== "undefined" && (window.SpeechRecognition || window.webkitSpeechRecognition);
93
94 // Responsive placeholder text
95 const placeholderText = useMemo(
96 () => (isSmallScreen ? "Message..." : "Message, paste image, or attach file..."),
97 [isSmallScreen],
98 );
99
100 // Track screen size for responsive placeholder
101 useEffect(() => {
102 const handleResize = () => {
103 setIsSmallScreen(window.innerWidth < 480);
104 };
105 window.addEventListener("resize", handleResize);
106 return () => window.removeEventListener("resize", handleResize);
107 }, []);
108
109 const stopListening = useCallback(() => {
110 if (recognitionRef.current) {
111 recognitionRef.current.stop();
112 recognitionRef.current = null;
113 }
114 setIsListening(false);
115 }, []);
116
117 const startListening = useCallback(() => {
118 if (!speechRecognitionAvailable) return;
119
120 const SpeechRecognitionClass = window.SpeechRecognition || window.webkitSpeechRecognition;
121 const recognition = new SpeechRecognitionClass();
122
123 recognition.continuous = true;
124 recognition.interimResults = true;
125 recognition.lang = navigator.language || "en-US";
126
127 // Capture current message as base text
128 setMessage((current) => {
129 baseTextRef.current = current;
130 finalizedTextRef.current = "";
131 return current;
132 });
133
134 recognition.onresult = (event: SpeechRecognitionEvent) => {
135 let finalTranscript = "";
136 let interimTranscript = "";
137
138 for (let i = event.resultIndex; i < event.results.length; i++) {
139 const transcript = event.results[i][0].transcript;
140 if (event.results[i].isFinal) {
141 finalTranscript += transcript;
142 } else {
143 interimTranscript += transcript;
144 }
145 }
146
147 // Accumulate finalized text
148 if (finalTranscript) {
149 finalizedTextRef.current += finalTranscript;
150 }
151
152 // Build the full message: base + finalized + interim
153 const base = baseTextRef.current;
154 const needsSpace = base.length > 0 && !/\s$/.test(base);
155 const spacer = needsSpace ? " " : "";
156 const fullText = base + spacer + finalizedTextRef.current + interimTranscript;
157
158 setMessage(fullText);
159 };
160
161 recognition.onerror = (event) => {
162 console.error("Speech recognition error:", event.error);
163 stopListening();
164 };
165
166 recognition.onend = () => {
167 setIsListening(false);
168 recognitionRef.current = null;
169 };
170
171 recognitionRef.current = recognition;
172 recognition.start();
173 setIsListening(true);
174 }, [speechRecognitionAvailable, stopListening]);
175
176 const toggleListening = useCallback(() => {
177 if (isListening) {
178 stopListening();
179 } else {
180 startListening();
181 }
182 }, [isListening, startListening, stopListening]);
183
184 // Cleanup on unmount
185 useEffect(() => {
186 return () => {
187 if (recognitionRef.current) {
188 recognitionRef.current.abort();
189 }
190 };
191 }, []);
192
193 const uploadFile = async (file: File) => {
194 // Add a loading indicator at the end of the current message
195 const loadingText = `[uploading ${file.name}...]`;
196 setMessage((prev) => (prev ? prev + " " : "") + loadingText);
197 setUploadsInProgress((prev) => prev + 1);
198
199 try {
200 const formData = new FormData();
201 formData.append("file", file);
202
203 const response = await fetch("/api/upload", {
204 method: "POST",
205 headers: { "X-Shelley-Request": "1" },
206 body: formData,
207 });
208
209 if (!response.ok) {
210 throw new Error(`Upload failed: ${response.statusText}`);
211 }
212
213 const data = await response.json();
214
215 // Replace the loading placeholder with the actual file path
216 setMessage((currentMessage) => currentMessage.replace(loadingText, `[${data.path}]`));
217 } catch (error) {
218 console.error("Failed to upload file:", error);
219 // Replace loading indicator with error message
220 const errorText = `[upload failed: ${error instanceof Error ? error.message : "unknown error"}]`;
221 setMessage((currentMessage) => currentMessage.replace(loadingText, errorText));
222 } finally {
223 setUploadsInProgress((prev) => prev - 1);
224 }
225 };
226
227 const handlePaste = (event: React.ClipboardEvent) => {
228 // Check clipboard items (works on both desktop and mobile)
229 // Mobile browsers often don't populate clipboardData.files, but items works
230 const items = event.clipboardData?.items;
231 if (items) {
232 for (let i = 0; i < items.length; i++) {
233 const item = items[i];
234 if (item.kind === "file") {
235 const file = item.getAsFile();
236 if (file) {
237 event.preventDefault();
238 // Fire and forget - uploadFile handles state updates internally.
239 uploadFile(file);
240 // Restore focus after React updates. We use setTimeout(0) to ensure
241 // the focus happens after React's state update commits to the DOM.
242 // The timeout must be longer than 0 to reliably work across browsers.
243 setTimeout(() => {
244 textareaRef.current?.focus();
245 }, 10);
246 return;
247 }
248 }
249 }
250 }
251 };
252
253 const handleDragOver = (event: React.DragEvent) => {
254 event.preventDefault();
255 event.stopPropagation();
256 };
257
258 const handleDragEnter = (event: React.DragEvent) => {
259 event.preventDefault();
260 event.stopPropagation();
261 setDragCounter((prev) => prev + 1);
262 };
263
264 const handleDragLeave = (event: React.DragEvent) => {
265 event.preventDefault();
266 event.stopPropagation();
267 setDragCounter((prev) => prev - 1);
268 };
269
270 const handleDrop = async (event: React.DragEvent) => {
271 event.preventDefault();
272 event.stopPropagation();
273 setDragCounter(0);
274
275 if (event.dataTransfer && event.dataTransfer.files.length > 0) {
276 // Process all dropped files
277 for (let i = 0; i < event.dataTransfer.files.length; i++) {
278 const file = event.dataTransfer.files[i];
279 await uploadFile(file);
280 }
281 }
282 };
283
284 const handleAttachClick = () => {
285 fileInputRef.current?.click();
286 };
287
288 const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
289 const files = event.target.files;
290 if (!files || files.length === 0) return;
291
292 for (let i = 0; i < files.length; i++) {
293 const file = files[i];
294 await uploadFile(file);
295 }
296
297 // Reset input so same file can be selected again
298 event.target.value = "";
299 };
300
301 // Auto-insert injected text (diff comments) directly into the textarea
302 useEffect(() => {
303 if (injectedText) {
304 setMessage((prev) => {
305 const needsNewline = prev.length > 0 && !prev.endsWith("\n");
306 return prev + (needsNewline ? "\n\n" : "") + injectedText;
307 });
308 onClearInjectedText?.();
309 // Focus the textarea after inserting
310 setTimeout(() => textareaRef.current?.focus(), 0);
311 }
312 }, [injectedText, onClearInjectedText]);
313
314 const handleSubmit = async (e: React.FormEvent) => {
315 e.preventDefault();
316 if (message.trim() && !disabled && !submitting && uploadsInProgress === 0) {
317 // Stop listening if we were recording
318 if (isListening) {
319 stopListening();
320 }
321
322 const messageToSend = message;
323 setSubmitting(true);
324 try {
325 await onSend(messageToSend);
326 // Only clear on success
327 setMessage("");
328 // Clear persisted draft on successful send
329 if (persistKey) {
330 localStorage.removeItem(PERSIST_KEY_PREFIX + persistKey);
331 }
332 } catch {
333 // Keep the message on error so user can retry
334 } finally {
335 setSubmitting(false);
336 }
337 }
338 };
339
340 const handleKeyDown = (e: React.KeyboardEvent) => {
341 // Don't submit while IME is composing (e.g., converting Japanese hiragana to kanji)
342 if (e.nativeEvent.isComposing) {
343 return;
344 }
345 if (e.key === "Enter" && !e.shiftKey) {
346 // On mobile, let Enter create newlines since there's a send button
347 // I'm not convinced the divergence from desktop is the correct answer,
348 // but we can try it and see how it feels.
349 const isMobile = "ontouchstart" in window;
350 if (isMobile) {
351 return;
352 }
353 e.preventDefault();
354 handleSubmit(e);
355 }
356 };
357
358 const adjustTextareaHeight = () => {
359 if (textareaRef.current) {
360 textareaRef.current.style.height = "auto";
361 const scrollHeight = textareaRef.current.scrollHeight;
362 const maxHeight = 200; // Maximum height in pixels
363 textareaRef.current.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
364 }
365 };
366
367 useEffect(() => {
368 adjustTextareaHeight();
369 }, [message]);
370
371 // Persist draft to localStorage when persistKey is set
372 useEffect(() => {
373 if (persistKey) {
374 if (message) {
375 localStorage.setItem(PERSIST_KEY_PREFIX + persistKey, message);
376 } else {
377 localStorage.removeItem(PERSIST_KEY_PREFIX + persistKey);
378 }
379 }
380 }, [message, persistKey]);
381
382 useEffect(() => {
383 if (autoFocus && textareaRef.current) {
384 // Use setTimeout to ensure the component is fully rendered
385 setTimeout(() => {
386 textareaRef.current?.focus();
387 }, 0);
388 }
389 }, [autoFocus]);
390
391 // Handle virtual keyboard appearance on mobile (especially Android Firefox)
392 // The visualViewport API lets us detect when the keyboard shrinks the viewport
393 useEffect(() => {
394 if (typeof window === "undefined" || !window.visualViewport) {
395 return;
396 }
397
398 const handleViewportResize = () => {
399 // Only scroll if our textarea is focused (keyboard is for us)
400 if (document.activeElement === textareaRef.current) {
401 // Small delay to let the viewport settle after resize
402 requestAnimationFrame(() => {
403 textareaRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
404 });
405 }
406 };
407
408 window.visualViewport.addEventListener("resize", handleViewportResize);
409 return () => {
410 window.visualViewport?.removeEventListener("resize", handleViewportResize);
411 };
412 }, []);
413
414 const isDisabled = disabled || uploadsInProgress > 0;
415 const canSubmit = message.trim() && !isDisabled && !submitting;
416
417 const isDraggingOver = dragCounter > 0;
418 // Check if user is typing a shell command (starts with !)
419 const isShellMode = message.trimStart().startsWith("!");
420 // Note: injectedText is auto-inserted via useEffect, no manual UI needed
421
422 return (
423 <div
424 className={`message-input-container ${isDraggingOver ? "drag-over" : ""} ${isShellMode ? "shell-mode" : ""}`}
425 onDragOver={handleDragOver}
426 onDragEnter={handleDragEnter}
427 onDragLeave={handleDragLeave}
428 onDrop={handleDrop}
429 >
430 {isDraggingOver && (
431 <div className="drag-overlay">
432 <div className="drag-overlay-content">Drop files here</div>
433 </div>
434 )}
435 <form onSubmit={handleSubmit} className="message-input-form">
436 <input
437 type="file"
438 ref={fileInputRef}
439 onChange={handleFileSelect}
440 style={{ display: "none" }}
441 multiple
442 accept="image/*,video/*,audio/*,.pdf,.txt,.md,.json,.csv,.xml,.html,.css,.js,.ts,.tsx,.jsx,.py,.go,.rs,.java,.c,.cpp,.h,.hpp,.sh,.yaml,.yml,.toml,.sql,.log,*"
443 aria-hidden="true"
444 />
445 {isShellMode && (
446 <div className="shell-mode-indicator" title="This will run as a shell command">
447 <svg
448 width="16"
449 height="16"
450 viewBox="0 0 24 24"
451 fill="none"
452 stroke="currentColor"
453 strokeWidth="2"
454 >
455 <polyline points="4 17 10 11 4 5" />
456 <line x1="12" y1="19" x2="20" y2="19" />
457 </svg>
458 </div>
459 )}
460 <textarea
461 ref={textareaRef}
462 value={message}
463 onChange={(e) => setMessage(e.target.value)}
464 onKeyDown={handleKeyDown}
465 onPaste={handlePaste}
466 onFocus={() => {
467 // Scroll to bottom after keyboard animation settles
468 if (onFocus) {
469 requestAnimationFrame(() => requestAnimationFrame(onFocus));
470 }
471 }}
472 placeholder={placeholderText}
473 className="message-textarea"
474 disabled={isDisabled}
475 rows={1}
476 aria-label="Message input"
477 data-testid="message-input"
478 autoFocus={autoFocus}
479 />
480 <button
481 type="button"
482 onClick={handleAttachClick}
483 disabled={isDisabled}
484 className="message-attach-btn"
485 aria-label="Attach file"
486 data-testid="attach-button"
487 >
488 <svg
489 fill="none"
490 stroke="currentColor"
491 strokeWidth="2"
492 viewBox="0 0 24 24"
493 width="20"
494 height="20"
495 >
496 <path
497 strokeLinecap="round"
498 strokeLinejoin="round"
499 d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
500 />
501 </svg>
502 </button>
503 {speechRecognitionAvailable && (
504 <button
505 type="button"
506 onClick={toggleListening}
507 disabled={isDisabled}
508 className={`message-voice-btn ${isListening ? "listening" : ""}`}
509 aria-label={isListening ? "Stop voice input" : "Start voice input"}
510 data-testid="voice-button"
511 >
512 {isListening ? (
513 <svg fill="currentColor" viewBox="0 0 24 24" width="20" height="20">
514 <circle cx="12" cy="12" r="6" />
515 </svg>
516 ) : (
517 <svg fill="currentColor" viewBox="0 0 24 24" width="20" height="20">
518 <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
519 </svg>
520 )}
521 </button>
522 )}
523 <button
524 type="submit"
525 disabled={!canSubmit}
526 className="message-send-btn"
527 aria-label="Send message"
528 data-testid="send-button"
529 >
530 {isDisabled || submitting ? (
531 <div className="flex items-center justify-center">
532 <div className="spinner spinner-small" style={{ borderTopColor: "white" }}></div>
533 </div>
534 ) : (
535 <svg fill="currentColor" viewBox="0 0 24 24" width="20" height="20">
536 <path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z" />
537 </svg>
538 )}
539 </button>
540 </form>
541 </div>
542 );
543}
544
545export default MessageInput;