Files
triqura-ecd/components/cortex/chat/chat-panel.tsx
colinislit cdeea793bd feat(cortex): update chat/nudge flow, deepgram streaming and agenda form
Refines chat panel, nudge messages and artifact rendering, reworks
deepgram token/streaming handling, and adds test tooling deps
(playwright, cypress) to package.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 22:37:46 +02:00

621 lines
23 KiB
TypeScript

'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<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Ref for chat input (for keyboard shortcuts)
const chatInputRef = useRef<ChatInputHandle>(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 (
<div className="h-full flex flex-col bg-white">
{/* Chat messages area - scrollable */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto p-6 relative"
>
{hasMessages ? (
<div className="flex flex-col space-y-3">
{chatMessages.map((message) => (
message.type === 'nudge' && message.nudge ? (
<NudgeChatMessage
key={message.id}
suggestion={message.nudge}
onAccept={(id) => handleAcceptNudge(id, message.nudge)}
onDismiss={handleDismissNudge}
/>
) : (
<ChatMessage key={message.id} message={message} showTimestamp />
)
))}
{/* V2: Processing indicator while AI is thinking */}
{isStreaming && (
<div className="self-start">
<ProcessingIndicator message="Even nadenken..." />
</div>
)}
{/* V2: Multi-intent action chain (feature flagged) */}
{isFeatureEnabled('CORTEX_MULTI_INTENT') && (
<AnimatePresence mode="wait">
{showActionChain && (
<ActionChainCard
key={activeChain.id}
chain={activeChain}
onConfirmAction={handleConfirmAction}
onSkipAction={handleSkipAction}
onRetryAction={handleRetryAction}
onDismissChain={handleDismissChain}
/>
)}
</AnimatePresence>
)}
{/* V2: Clarification card for ambiguous input (feature flagged) */}
{isFeatureEnabled('CORTEX_V2_ENABLED') && (
<AnimatePresence mode="wait">
{pendingClarification && (
<ClarificationCard
key="clarification"
question={pendingClarification.question}
options={pendingClarification.options}
originalInput={pendingClarification.originalInput}
onSelectOption={handleSelectClarification}
onDismiss={handleDismissClarification}
/>
)}
</AnimatePresence>
)}
{/* Invisible element to scroll to */}
<div ref={messagesEndRef} />
</div>
) : (
<ChatEmptyState
onSelectAction={handleSelectSuggestion}
activePatientName={activePatientName}
/>
)}
{/* Scroll to bottom button */}
{showScrollButton && (
<button
onClick={() => scrollToBottom('smooth')}
className={cn(
'absolute bottom-4 right-4 z-10',
'bg-white border border-slate-300 rounded-full p-2',
'shadow-lg hover:shadow-xl',
'transition-all duration-200',
'hover:bg-slate-50 active:scale-95',
'flex items-center gap-2 text-sm text-slate-700 font-medium px-3 py-2'
)}
aria-label="Scroll naar laatste bericht"
>
<ArrowDown className="w-4 h-4" />
<span>Scroll naar beneden</span>
</button>
)}
</div>
{/* Suggestion strip above input */}
<ChatSuggestions
isMinimized={isSuggestionsMinimized}
onToggleMinimize={() => setIsSuggestionsMinimized(!isSuggestionsMinimized)}
onSelectSuggestion={handleSelectSuggestion}
activePatientName={activePatientName}
/>
{/* Chat input */}
<ChatInput
ref={chatInputRef}
disabled={isStreaming}
onSend={async (message) => {
// 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}`,
});
}
);
}}
/>
</div>
);
}