MessageInput.tsx

  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;