'use client'; /** * Chat Input Component (v3.0) * * Text input onderaan chat panel met Enter to submit en Shift+Enter voor nieuwe regel. * * Epic: E2 (Chat Panel & Messages) * Story: E2.S4 (ChatInput component) */ import { useState, useRef, useEffect, KeyboardEvent, ChangeEvent, forwardRef, useImperativeHandle, } from 'react'; import { Send, Mic, Square, Loader2 } from 'lucide-react'; import { useCortexStore } from '@/stores/cortex-store'; import { cn } from '@/lib/utils'; import { useCortexVoice } from '@/lib/cortex/use-cortex-voice'; import { usePatientSelection } from '@/lib/cortex/hooks/use-patient-selection'; import type { PatientSearchResult } from '@/lib/cortex/hooks/use-patient-search'; import { PatientMentionDropdown } from '../command-center/patient-mention-dropdown'; interface ChatInputProps { placeholder?: string; onSend?: (message: string) => void; disabled?: boolean; } export interface ChatInputHandle { focus: () => void; clear: () => void; setValue: (value: string) => void; } export const ChatInput = forwardRef(function ChatInput({ placeholder = 'Typ of spreek wat je wilt doen...', onSend, disabled = false, }, ref) { const textareaRef = useRef(null); const addChatMessage = useCortexStore((s) => s.addChatMessage); const inputValue = useCortexStore((s) => s.inputValue); const setInputValue = useCortexStore((s) => s.setInputValue); const clearInput = useCortexStore((s) => s.clearInput); const { isRecording, isConnecting, error: voiceError, startRecording, stopRecording, isBrowserSupported, } = useCortexVoice(); // @mention state (E2) const [mentionState, setMentionState] = useState<{ query: string; startIndex: number; } | null>(null); // Patient selection hook const { selectPatient } = usePatientSelection({ showSuccessToast: false, }); useEffect(() => { if (!textareaRef.current) return; textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; }, [inputValue]); // Expose focus, clear, and setValue methods to parent useImperativeHandle(ref, () => ({ focus: () => { textareaRef.current?.focus(); }, clear: () => { clearInput(); if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } }, setValue: (value: string) => { setInputValue(value); // Auto-resize after setting value if (textareaRef.current) { textareaRef.current.style.height = 'auto'; // Use setTimeout to ensure the value is set before measuring setTimeout(() => { if (textareaRef.current) { textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; } }, 0); } // Focus the input after setting value textareaRef.current?.focus(); }, })); // @mention detection const detectMention = (value: string) => { const lastAtIndex = value.lastIndexOf('@'); if (lastAtIndex !== -1) { const afterAt = value.slice(lastAtIndex + 1); // Active if: no space after @, and at least 1 char if (!afterAt.includes(' ') && afterAt.length > 0) { setMentionState({ query: afterAt, startIndex: lastAtIndex }); return; } } setMentionState(null); }; // Handle @mention selection const handleMentionSelect = (patient: PatientSearchResult) => { if (!mentionState) return; // Replace @query with @name in input const before = inputValue.slice(0, mentionState.startIndex); const after = inputValue.slice(mentionState.startIndex + mentionState.query.length + 1); const newValue = `${before}@${patient.name} ${after}`.trim(); setInputValue(newValue); // Set activePatient selectPatient(patient); // Close dropdown setMentionState(null); // Focus back on textarea textareaRef.current?.focus(); }; // Handle input change and auto-resize const handleChange = (e: ChangeEvent) => { const value = e.target.value; setInputValue(value); detectMention(value); // Auto-resize textarea if (textareaRef.current) { textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; } }; // Handle submit const handleSubmit = () => { const trimmedValue = inputValue.trim(); if (!trimmedValue || disabled) return; // Add user message to store addChatMessage({ type: 'user', content: trimmedValue, }); if (isRecording) { stopRecording(); } // Call optional onSend callback onSend?.(trimmedValue); // Clear input clearInput(); // Reset textarea height if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } // Focus back on textarea textareaRef.current?.focus(); }; // Handle keyboard shortcuts const handleKeyDown = (e: KeyboardEvent) => { // Enter to submit (unless Shift is pressed) if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(); } // Escape: close dropdown first, then clear input if (e.key === 'Escape') { e.preventDefault(); if (mentionState) { setMentionState(null); return; } if (isRecording) { stopRecording(); } clearInput(); if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } } // Shift+Enter for new line (default behavior, no need to handle) }; const handleVoiceClick = async () => { if (disabled || isConnecting || !isBrowserSupported) return; if (isRecording) { stopRecording(); textareaRef.current?.focus(); return; } await startRecording(); textareaRef.current?.focus(); }; return (
{/* @mention dropdown */} {mentionState && ( setMentionState(null)} /> )} {/* Textarea input */}