'use client'; /** * Chat Panel (v4.0) * * Chat interface met scrollable message list, auto-scroll, en scroll-lock detection. * V2 integratie: ActionChainCard, ClarificationCard, ProcessingIndicator * * Epic: E2 (Chat Panel & Messages), E3 (UI Components) * Story: E2.S3 (ChatPanel component - scrolling), E3 Integration */ import { useRef, useEffect, useState, useCallback } from 'react'; import { ArrowDown } from 'lucide-react'; import { AnimatePresence } from 'framer-motion'; import { ChatMessage } from './chat-message'; import { NudgeChatMessage } from './nudge-chat-message'; import { ChatInput, ChatInputHandle } from './chat-input'; import { ChatSuggestions } from './chat-suggestions'; import { ChatEmptyState } from './chat-empty-state'; import { ActionChainCard } from './action-chain-card'; import { ClarificationCard } from './clarification-card'; import { ProcessingIndicator } from './processing-indicator'; import { useCortexStore, type ChatMessage as ChatMessageType, type NoShowFlowState } from '@/stores/cortex-store'; import type { NudgeSuggestion } from '@/lib/cortex/types'; import { sendChatMessage } from '@/lib/cortex/chat-api'; import { parseActionFromResponse, shouldOpenArtifact, routeIntentToArtifact, getDefaultConfirmationMessage } from '@/lib/cortex/action-parser'; import { evaluateNudge } from '@/lib/cortex/nudge'; import { isFeatureEnabled } from '@/lib/config/feature-flags'; import { cn } from '@/lib/utils'; export function ChatPanel() { // Chat state const chatMessages = useCortexStore((s) => s.chatMessages); const addChatMessage = useCortexStore((s) => s.addChatMessage); const updateLastMessage = useCortexStore((s) => s.updateLastMessage); const setStreaming = useCortexStore((s) => s.setStreaming); const isStreaming = useCortexStore((s) => s.isStreaming); const setPendingAction = useCortexStore((s) => s.setPendingAction); const activePatient = useCortexStore((s) => s.activePatient); const shift = useCortexStore((s) => s.shift); // V2 Chain state const activeChain = useCortexStore((s) => s.activeChain); const updateActionStatus = useCortexStore((s) => s.updateActionStatus); const completeChain = useCortexStore((s) => s.completeChain); // V2 Clarification state const pendingClarification = useCortexStore((s) => s.pendingClarification); const setPendingClarification = useCortexStore((s) => s.setPendingClarification); const resolveClarification = useCortexStore((s) => s.resolveClarification); // Artifact state (E5.S2) const openArtifact = useCortexStore((s) => s.openArtifact); // Nudge state (chat-based nudges) const acceptSuggestion = useCortexStore((s) => s.acceptSuggestion); const dismissSuggestion = useCortexStore((s) => s.dismissSuggestion); // No-show flow state const setNoShowStep = useCortexStore((s) => s.setNoShowStep); const setNoShowContext = useCortexStore((s) => s.setNoShowContext); const setNoShowProcessing = useCortexStore((s) => s.setNoShowProcessing); const resetNoShowFlow = useCortexStore((s) => s.resetNoShowFlow); // Refs for scrolling const scrollContainerRef = useRef(null); const messagesEndRef = useRef(null); // Ref for chat input (for keyboard shortcuts) const chatInputRef = useRef(null); // Scroll-lock state const [isScrolledUp, setIsScrolledUp] = useState(false); const [showScrollButton, setShowScrollButton] = useState(false); // Intent Helper state: minimize suggestions after first message const [isSuggestionsMinimized, setIsSuggestionsMinimized] = useState(false); const hasAutoMinimizedRef = useRef(false); const hasMessages = chatMessages.length > 0; // Get active patient name for suggestion placeholders const activePatientName = activePatient ? `${activePatient.name_given?.[0] || ''} ${activePatient.name_family || ''}`.trim() : undefined; // Scroll to bottom function const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { messagesEndRef.current?.scrollIntoView({ behavior }); setIsScrolledUp(false); setShowScrollButton(false); }, []); // Detect scroll position (scroll-lock detection) const handleScroll = useCallback(() => { if (!scrollContainerRef.current) return; const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; const isNearBottom = scrollHeight - scrollTop - clientHeight < 100; // 100px threshold setIsScrolledUp(!isNearBottom); // Show scroll button only if scrolled up AND there are messages setShowScrollButton(!isNearBottom && hasMessages); }, [hasMessages]); // Auto-scroll to latest message when new message arrives (unless user scrolled up) useEffect(() => { if (!isScrolledUp && hasMessages) { scrollToBottom('smooth'); } }, [chatMessages.length, isScrolledUp, hasMessages, scrollToBottom]); // Initial scroll to bottom on mount useEffect(() => { scrollToBottom('auto'); }, [scrollToBottom]); // Auto-minimize suggestions after first message is sent (only once) useEffect(() => { if (hasMessages && !hasAutoMinimizedRef.current) { hasAutoMinimizedRef.current = true; setIsSuggestionsMinimized(true); } }, [hasMessages]); // Cleanup no-show flow bij unmount useEffect(() => { return () => { const { noShowFlow } = useCortexStore.getState(); if (noShowFlow.step !== 'idle' && noShowFlow.step !== 'done') { resetNoShowFlow(); } }; }, [resetNoShowFlow]); // Handle suggestion selection - fill input with selected text const handleSelectSuggestion = useCallback((text: string) => { chatInputRef.current?.setValue(text); }, []); // Global keyboard shortcuts useEffect(() => { const handleGlobalKeyDown = (e: KeyboardEvent) => { // ⌘K or Ctrl+K to focus chat input if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); chatInputRef.current?.focus(); } }; window.addEventListener('keydown', handleGlobalKeyDown); return () => window.removeEventListener('keydown', handleGlobalKeyDown); }, []); // No-show flow: stap 2→3 — annuleer afspraak + check concept brief const handleNoShowCancelStep = useCallback(async (_suggestion: NudgeSuggestion) => { const { isNoShowProcessing } = useCortexStore.getState(); if (isNoShowProcessing) return; setNoShowProcessing(true); setNoShowStep('waiting_cancel'); addChatMessage({ type: 'assistant', content: 'Bezig met annuleren...' }); try { const patientId = activePatient?.id ?? 'demo-patient-001'; // Stap 1: annuleer de afspraak const cancelRes = await fetch('/api/cortex/noshow/cancel', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appointmentId: 'mock-appt-noshow-001', patientId }), }); if (!cancelRes.ok) throw new Error('Annuleren mislukt'); // Stap 2: check op concept brief const ctxRes = await fetch(`/api/cortex/noshow/context?patientId=${patientId}`); const ctx = await ctxRes.json(); if (ctx.hasConceptBrief) { setNoShowContext({ documentId: ctx.document.id, originalContent: ctx.document.content }); setNoShowStep('waiting_brief'); // Construeer nudge 2 handmatig const briefNudge: NudgeSuggestion = { id: `nudge-noshow-brief-${Date.now()}`, trigger: { actionId: 'noshow-cancel-done', intent: 'cancel_appointment', entities: {} }, suggestion: { intent: 'register_no_show', entities: {}, message: 'Afspraak geannuleerd. Er staat nog een concept huisartsbrief klaar. Zal ik daar de No Show in verwerken?', rationale: 'noshow-brief-check', }, status: 'pending', priority: 'high', expiresAt: new Date(Date.now() + 5 * 60 * 1000), createdAt: new Date(), }; addChatMessage({ type: 'nudge', content: briefNudge.suggestion.message, nudge: briefNudge }); } else { setNoShowStep('done'); addChatMessage({ type: 'assistant', content: 'Afspraak geannuleerd als No Show. Er zijn geen openstaande conceptbrieven gevonden.', }); } } catch { setNoShowStep('idle'); addChatMessage({ type: 'error', content: 'Er is iets misgegaan bij het annuleren. Probeer het opnieuw.' }); } finally { setNoShowProcessing(false); } }, [activePatient, setNoShowStep, setNoShowContext, setNoShowProcessing, addChatMessage]); // No-show flow: stap 4→5 — rescript brief + open artifact const handleNoShowRescriptStep = useCallback(async () => { const { isNoShowProcessing, noShowFlow } = useCortexStore.getState(); if (isNoShowProcessing) return; setNoShowProcessing(true); setNoShowStep('brief_open'); addChatMessage({ type: 'assistant', content: 'Huisartsbrief aanpassen...' }); try { const res = await fetch('/api/cortex/noshow/rescript', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ documentId: noShowFlow.documentId ?? 'mock-brief-noshow-001', originalContent: noShowFlow.originalContent ?? '', patientId: activePatient?.id ?? 'demo-patient-001', }), }); const result = await res.json(); openArtifact({ type: 'register_no_show', title: 'Huisartsbrief n.a.v. intake', prefill: { documentId: result.documentId, content: result.rescriptedContent, title: 'Huisartsbrief n.a.v. intake', originalContent: result.originalContent, rescriptWarning: result.warning, }, }); addChatMessage({ type: 'assistant', content: 'Huisartsbrief is aangepast. Kijk of je hiermee akkoord bent.', }); } catch { setNoShowStep('waiting_brief'); addChatMessage({ type: 'error', content: 'Herschrijven mislukt. Probeer het opnieuw.' }); } finally { setNoShowProcessing(false); } }, [activePatient, setNoShowStep, setNoShowProcessing, addChatMessage, openArtifact]); // V2 Chain action handlers (E5.S2) const handleConfirmAction = useCallback((actionId: string) => { // Find the action in the active chain const action = activeChain?.actions.find((a) => a.id === actionId); if (!action || !activeChain) { console.error('[ChatPanel] Action not found:', actionId); return; } console.log('[ChatPanel] Confirming action:', actionId, action.intent); updateActionStatus(actionId, 'executing'); // Route to artifact (uses existing artifact system) const artifact = routeIntentToArtifact( action.intent, action.entities, action.confidence ); if (artifact) { console.log('[ChatPanel] Opening artifact:', artifact.type); openArtifact({ type: artifact.type, prefill: artifact.prefill, title: artifact.title, }); } // Mark as success (artifact is now open for user to complete) updateActionStatus(actionId, 'success'); // E5.S2: Trigger nudge evaluation after successful action // Now adds nudges as chat messages instead of toast if (isFeatureEnabled('CORTEX_NUDGE')) { const suggestions = evaluateNudge({ intent: action.intent, actionId, entities: action.entities, content: action.entities.content, }); if (suggestions.length > 0) { console.log('[ChatPanel] Nudge suggestions (chat-based):', suggestions.length); suggestions.forEach((suggestion) => { // Add as chat message with nudge type addChatMessage({ type: 'nudge', content: suggestion.suggestion.message, nudge: suggestion, }); }); } } }, [activeChain, updateActionStatus, openArtifact, addChatMessage]); const handleSkipAction = useCallback((actionId: string) => { console.log('[ChatPanel] Skipping action:', actionId); updateActionStatus(actionId, 'skipped'); }, [updateActionStatus]); const handleRetryAction = useCallback((actionId: string) => { console.log('[ChatPanel] Retrying action:', actionId); updateActionStatus(actionId, 'pending'); // Re-trigger confirmation flow setTimeout(() => { updateActionStatus(actionId, 'confirming'); }, 100); }, [updateActionStatus]); const handleDismissChain = useCallback(() => { console.log('[ChatPanel] Dismissing chain'); completeChain(); }, [completeChain]); const handleSelectClarification = useCallback((option: string) => { console.log('[ChatPanel] Clarification selected:', option); resolveClarification(option); // TODO: E5 - Re-process with selected option }, [resolveClarification]); const handleDismissClarification = useCallback(() => { console.log('[ChatPanel] Clarification dismissed'); setPendingClarification(null); }, [setPendingClarification]); // Nudge handlers (chat-based nudges) const handleAcceptNudge = useCallback(async (suggestionId: string, suggestion: ChatMessageType['nudge']) => { console.log('[ChatPanel] Nudge accepted:', suggestionId); acceptSuggestion(suggestionId); if (!suggestion) return; // No-show flow stap 2→3: declarabiliteitscheck geaccepteerd if (suggestion.trigger.intent === 'register_no_show') { await handleNoShowCancelStep(suggestion); return; } // No-show flow stap 4→5: brief-check geaccepteerd if (suggestion.suggestion.rationale === 'noshow-brief-check') { await handleNoShowRescriptStep(); return; } // Generieke flow voor alle andere nudges const artifact = routeIntentToArtifact( suggestion.suggestion.intent, suggestion.suggestion.entities, 0.9 ); if (artifact) { console.log('[ChatPanel] Opening artifact from nudge:', artifact.type); openArtifact({ type: artifact.type, prefill: artifact.prefill, title: artifact.title, }); } }, [acceptSuggestion, openArtifact, handleNoShowCancelStep, handleNoShowRescriptStep]); const handleDismissNudge = useCallback((suggestionId: string) => { console.log('[ChatPanel] Nudge dismissed:', suggestionId); dismissSuggestion(suggestionId); }, [dismissSuggestion]); // E5.S2: Sequential chain execution - auto-advance to next action useEffect(() => { if (!activeChain) return; const actions = activeChain.actions; const completedStatuses = ['success', 'skipped', 'failed']; // Count completed actions const completedCount = actions.filter((a) => completedStatuses.includes(a.status) ).length; // Find next pending action const nextPending = actions.find((a) => a.status === 'pending'); // If there's a completed action and a pending one, auto-advance if (completedCount > 0 && nextPending) { // Small delay for UI feedback before advancing const timer = setTimeout(() => { updateActionStatus(nextPending.id, 'confirming'); }, 300); return () => clearTimeout(timer); } // If all actions are complete, finish the chain if (completedCount === actions.length && actions.length > 0) { const timer = setTimeout(() => { console.log('[ChatPanel] All actions complete, finishing chain'); completeChain(); }, 500); return () => clearTimeout(timer); } }, [activeChain, updateActionStatus, completeChain]); // Check if we should show multi-intent UI const showActionChain = activeChain && activeChain.actions.length > 1; return (
{/* Chat messages area - scrollable */}
{hasMessages ? (
{chatMessages.map((message) => ( message.type === 'nudge' && message.nudge ? ( handleAcceptNudge(id, message.nudge)} onDismiss={handleDismissNudge} /> ) : ( ) ))} {/* V2: Processing indicator while AI is thinking */} {isStreaming && (
)} {/* V2: Multi-intent action chain (feature flagged) */} {isFeatureEnabled('CORTEX_MULTI_INTENT') && ( {showActionChain && ( )} )} {/* V2: Clarification card for ambiguous input (feature flagged) */} {isFeatureEnabled('CORTEX_V2_ENABLED') && ( {pendingClarification && ( )} )} {/* Invisible element to scroll to */}
) : ( )} {/* Scroll to bottom button */} {showScrollButton && ( )}
{/* Suggestion strip above input */} setIsSuggestionsMinimized(!isSuggestionsMinimized)} onSelectSuggestion={handleSelectSuggestion} activePatientName={activePatientName} /> {/* Chat input */} { // E3.S1: Test streaming API with mock response setStreaming(true); // Add empty assistant message that will be filled by streaming addChatMessage({ type: 'assistant', content: '', }); let accumulatedContent = ''; await sendChatMessage( message, chatMessages, { activePatient: activePatient ? { id: activePatient.id, first_name: activePatient.name_given?.[0] || '', last_name: activePatient.name_family || '', } : null, shift, }, (chunk) => { // On each chunk, append to accumulated content accumulatedContent += chunk; // Filter out JSON blocks during streaming - users don't need to see the raw JSON // Show "Verwerken..." when JSON is being generated but no readable text yet const displayContent = accumulatedContent .replace(/```json[\s\S]*?```/g, '') // Remove complete JSON blocks .replace(/```json[\s\S]*$/g, '') // Remove incomplete JSON block at end .trim(); // If we have displayable content, show it; otherwise show processing message updateLastMessage(displayContent || 'Verwerken...'); }, () => { // On done - parse action from complete response setStreaming(false); // E3.S4: Parse action object from AI response const parsed = parseActionFromResponse(accumulatedContent); if (parsed.action) { console.log('[ChatPanel] Action detected:', parsed.action); // If textContent is empty but we have an action, generate a default confirmation message const displayContent = parsed.textContent.trim() || getDefaultConfirmationMessage(parsed.action.intent, parsed.action.entities); // Update last message with text content (or default) and action updateLastMessage(displayContent, parsed.action); // Store action in pendingAction for artifact opening (E3.S6) if (shouldOpenArtifact(parsed.action.confidence)) { setPendingAction(parsed.action); console.log('[ChatPanel] Pending action set (confidence:', parsed.action.confidence, ')'); } else { console.log('[ChatPanel] Action confidence too low:', parsed.action.confidence); } // No-show nudge trigger na register_no_show classificatie if (parsed.action.intent === 'register_no_show' && isFeatureEnabled('CORTEX_NUDGE')) { const suggestions = evaluateNudge({ intent: 'register_no_show', actionId: crypto.randomUUID(), entities: {}, // register_no_show heeft geen entiteiten nodig content: message, }); suggestions.forEach((suggestion) => { addChatMessage({ type: 'nudge', content: suggestion.suggestion.message, nudge: suggestion }); }); } } else { console.log('[ChatPanel] No action detected in response'); } }, (error) => { // On error setStreaming(false); addChatMessage({ type: 'error', content: `Er ging iets mis: ${error}`, }); } ); }} />
); }