'use client'; /** * Command Input * * Bottom input bar for text and voice commands. * Height: 64px (h-16) * * Features: * - Text input with dynamic placeholder * - Focus state with ring * - Send button (appears when input has value) * - Voice input with Deepgram streaming * - ⌘K shortcut hint */ import { forwardRef, useState, useEffect, useRef } from 'react'; import { useCortexStore } from '@/stores/cortex-store'; import { useCortexVoice } from '@/lib/cortex/use-cortex-voice'; import type { BlockType } from '@/lib/cortex/types'; import { Mic, MicOff, Send, Loader2 } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import { safeFetch, getErrorInfo } from '@/lib/cortex/error-handler'; import { routeIntentToArtifact } from '@/lib/cortex/action-parser'; import { usePatientSelection } from '@/lib/cortex/hooks/use-patient-selection'; import type { PatientSearchResult } from '@/lib/cortex/hooks/use-patient-search'; import { PatientMentionDropdown } from './patient-mention-dropdown'; export const CommandInput = forwardRef(function CommandInput(_, ref) { const { inputValue, setInputValue, clearInput, activePatient, activeBlock, isVoiceActive, openBlock, openArtifact, addRecentAction, } = useCortexStore(); const { toast } = useToast(); const { isRecording, isConnecting, isConnected, error: voiceError, startRecording, stopRecording, analyserNode, isBrowserSupported, } = useCortexVoice(); const [isProcessing, setIsProcessing] = useState(false); const waveformRef = useRef(null); const animationRef = useRef(null); // @mention state (E2.S1) const [mentionState, setMentionState] = useState<{ query: string; startIndex: number; } | null>(null); // Patient selection hook (E2.S3) const { selectPatient } = usePatientSelection({ showSuccessToast: false, // Don't show toast for @mention selection }); const hasValue = inputValue.trim().length > 0; // @mention detection (E2.S1) 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 input change with @mention detection const handleInputChange = (value: string) => { setInputValue(value); detectMention(value); }; // Handle @mention selection (E2.S3) const handleMentionSelect = (patient: PatientSearchResult) => { if (!mentionState) return; // 1. 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); // 2. Set activePatient via selection hook selectPatient(patient); // 3. Close dropdown setMentionState(null); }; // Waveform visualization useEffect(() => { if (!analyserNode || !waveformRef.current || !isRecording) { if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = null; } return; } const canvas = waveformRef.current; const ctx = canvas.getContext('2d'); if (!ctx) return; const bufferLength = analyserNode.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); const draw = () => { if (!isRecording) return; animationRef.current = requestAnimationFrame(draw); analyserNode.getByteFrequencyData(dataArray); ctx.fillStyle = 'rgb(248, 250, 252)'; // slate-50 ctx.fillRect(0, 0, canvas.width, canvas.height); const barWidth = (canvas.width / bufferLength) * 2.5; let x = 0; for (let i = 0; i < bufferLength; i++) { const barHeight = (dataArray[i] / 255) * canvas.height; // Gradient from blue to red based on amplitude const hue = 220 - (dataArray[i] / 255) * 40; ctx.fillStyle = `hsl(${hue}, 70%, 60%)`; ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight); x += barWidth + 1; } }; draw(); return () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); } }; }, [analyserNode, isRecording]); // Dynamic placeholder based on context const getPlaceholder = () => { if (isRecording) return 'Luisteren...'; if (isConnecting) return 'Verbinden met spraakherkenning...'; if (activePatient) { return `Actie voor ${activePatient.name_given[0]}... (bijv. "notitie medicatie")`; } return 'Typ of spreek je intentie... (bijv. "notitie jan medicatie")'; }; const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); if (!hasValue || isProcessing) return; // Stop recording if active if (isRecording) { stopRecording(); } const inputText = inputValue.trim(); setIsProcessing(true); try { // Call intent classification API const response = await safeFetch( '/api/intent/classify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: inputText }), }, { operation: 'Intent classificeren' } ); const result = await response.json(); const { intent, confidence, entities } = result; // Route intent to artifact using Epic 5.S1 routing logic const artifactConfig = routeIntentToArtifact(intent, entities, confidence); if (artifactConfig) { // Open artifact with routing configuration openArtifact({ type: artifactConfig.type, title: artifactConfig.title, prefill: artifactConfig.prefill, }); // Add to recent actions addRecentAction({ intent, label: inputText.slice(0, 50), // Truncate for display patientName: entities.patientName, }); clearInput(); } else { // Low confidence or missing required entities - show FallbackPicker openBlock('fallback', { content: inputText }); clearInput(); } } catch (error) { console.error('Error processing intent:', error); const statusCode = (error as any)?.statusCode; const errorInfo = getErrorInfo(error, { operation: 'Intent classificeren', statusCode, }); // Show error toast toast({ variant: 'destructive', title: errorInfo.title, description: errorInfo.description, }); // On error, show FallbackPicker so user can choose // This ensures the user's input is not lost openBlock('fallback', { content: inputText }); clearInput(); } finally { setIsProcessing(false); } }; const handleVoiceToggle = () => { if (isRecording) { stopRecording(); } else { startRecording(); } }; // Keyboard shortcut: Cmd/Ctrl+Enter to submit useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Cmd/Ctrl+Enter: submit command if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); handleSubmit(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [handleSubmit]); const isDisabled = isProcessing || activeBlock !== null; return ( ); });