"use client"; import type { ChatStatus } from "ai"; import { ArrowUp, Mic, Plus, Square, UserPlus, X } from "lucide-react"; import { type ChangeEvent, type KeyboardEvent, useCallback, useEffect, useRef, useState, } from "react"; import { useTranslation } from "react-i18next"; import { ModePicker } from "@/components/chat/mode-picker"; import { PatientFormDialog } from "@/components/chat/patient-form-dialog"; import type { ChatMode } from "@/lib/chat-modes"; import { cn } from "@/lib/utils"; type ChatInputProps = { onSubmit: (text: string, files: File[]) => void; status: ChatStatus; onStop?: () => void; mode: ChatMode; onModeChange: (mode: ChatMode) => void; }; const iconButton = "flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40"; // Minimal Web Speech API typings — not in this TS lib.dom. We only use a slice. interface SpeechRecognitionEventLike { readonly results: { readonly length: number; [index: number]: { readonly [index: number]: { transcript: string } }; }; } interface SpeechRecognitionLike { lang: string; interimResults: boolean; continuous: boolean; onresult: ((event: SpeechRecognitionEventLike) => void) | null; onend: (() => void) | null; onerror: (() => void) | null; start: () => void; stop: () => void; } type SpeechRecognitionCtor = new () => SpeechRecognitionLike; // Web Speech API lives under a vendor prefix in Chromium-based browsers and is // absent in others (e.g. Firefox). Resolve the constructor or null. function getSpeechRecognition(): SpeechRecognitionCtor | null { if (typeof window === "undefined") return null; const w = window as typeof window & { SpeechRecognition?: SpeechRecognitionCtor; webkitSpeechRecognition?: SpeechRecognitionCtor; }; return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null; } const pillButton = "flex h-8 items-center gap-1.5 rounded-lg px-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"; const contextPill = "flex h-7 items-center gap-1.5 rounded-md px-2 text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"; export function ChatInput({ onSubmit, status, onStop, mode, onModeChange, }: ChatInputProps) { const { t } = useTranslation(); const [value, setValue] = useState(""); const [files, setFiles] = useState([]); const [addOpen, setAddOpen] = useState(false); // Bumped on each open so the dialog remounts with a fresh file number + form. const [addKey, setAddKey] = useState(0); const fileInputRef = useRef(null); // Voice dictation (Web Speech API). Detected client-side so SSR markup and the // first client render agree (button starts disabled, enabled by the effect). const [speechSupported, setSpeechSupported] = useState(false); const [isListening, setIsListening] = useState(false); const recognitionRef = useRef(null); // The textarea contents when dictation started; transcript is appended to it. const dictationBaseRef = useRef(""); useEffect(() => { setSpeechSupported(getSpeechRecognition() !== null); return () => recognitionRef.current?.stop(); }, []); const toggleDictation = useCallback(() => { if (isListening) { recognitionRef.current?.stop(); return; } const Recognition = getSpeechRecognition(); if (!Recognition) return; const recognition = new Recognition(); recognitionRef.current = recognition; recognition.lang = navigator.language || "en-US"; recognition.interimResults = true; recognition.continuous = true; // Continue from where the text leaves off, with a separating space. dictationBaseRef.current = value ? `${value.replace(/\s*$/, "")} ` : ""; recognition.onresult = (event) => { let transcript = ""; for (let i = 0; i < event.results.length; i++) { transcript += event.results[i]?.[0]?.transcript ?? ""; } setValue(dictationBaseRef.current + transcript); }; const end = () => { setIsListening(false); recognitionRef.current = null; }; recognition.onend = end; recognition.onerror = end; recognition.start(); setIsListening(true); }, [isListening, value]); const isGenerating = status === "submitted" || status === "streaming"; const canSend = (value.trim().length > 0 || files.length > 0) && !isGenerating; const submit = useCallback(() => { const trimmed = value.trim(); // Allow submitting while generating — the panel queues it (Claude-style). if (!trimmed && files.length === 0) { return; } // Hand the raw files to the panel; it sends them as proper attachment parts // (rendered as chips, not raw inlined text) and the backend extracts any // text-like content for the model. onSubmit(trimmed, files); setValue(""); setFiles([]); }, [value, files, onSubmit]); const handleKeyDown = useCallback( (event: KeyboardEvent) => { if ( event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing ) { event.preventDefault(); submit(); } }, [submit] ); const handleFilesSelected = useCallback( (event: ChangeEvent) => { // Copy the files out NOW: `event.target.files` is a live FileList, and the // `event.target.value = ""` reset below empties it. React runs the // functional setState updater during a later render, so reading the // FileList inside the updater would see it already cleared (no file added, // no chip). Snapshot to a plain array first. const picked = Array.from(event.target.files ?? []); // Reset so picking the same file again still fires onChange. event.target.value = ""; if (picked.length > 0) { setFiles((prev) => [...prev, ...picked]); } }, [] ); const removeFile = useCallback((index: number) => { setFiles((prev) => prev.filter((_, i) => i !== index)); }, []); return ( <>
{ event.preventDefault(); submit(); }} className="w-full shrink-0 overflow-hidden rounded-[28px] border border-input bg-background shadow-sm transition-shadow focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/24 dark:bg-input/30" > {/* Textarea + toolbar, filling the rounded card. */}