- Updated DagnotatieBlock to use activePatient for prefill if no patientId is provided. - Enhanced PatientDashboardBlock to fallback to activePatient for patientId and name. - Adjusted usePatientSelection to handle pending actions and re-route after patient selection. - Refined chat components to manage nudge messages and integrate them into the chat flow. - Improved UI elements for better patient selection experience. This update enhances user experience by ensuring that the active patient context is utilized across multiple components, streamlining workflows and reducing manual input.
474 lines
17 KiB
TypeScript
474 lines
17 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 } from '@/stores/cortex-store';
|
|
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);
|
|
|
|
// 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]);
|
|
|
|
// 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);
|
|
}, []);
|
|
|
|
// 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((suggestionId: string, suggestion: ChatMessageType['nudge']) => {
|
|
console.log('[ChatPanel] Nudge accepted:', suggestionId);
|
|
acceptSuggestion(suggestionId);
|
|
|
|
if (suggestion) {
|
|
// Route to artifact with prefilled entities
|
|
const artifact = routeIntentToArtifact(
|
|
suggestion.suggestion.intent,
|
|
suggestion.suggestion.entities,
|
|
0.9 // High confidence for nudge-initiated actions
|
|
);
|
|
|
|
if (artifact) {
|
|
console.log('[ChatPanel] Opening artifact from nudge:', artifact.type);
|
|
openArtifact({
|
|
type: artifact.type,
|
|
prefill: artifact.prefill,
|
|
title: artifact.title,
|
|
});
|
|
}
|
|
}
|
|
}, [acceptSuggestion, openArtifact]);
|
|
|
|
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);
|
|
}
|
|
} else {
|
|
console.log('[ChatPanel] No action detected in response');
|
|
}
|
|
},
|
|
(error) => {
|
|
// On error
|
|
setStreaming(false);
|
|
addChatMessage({
|
|
type: 'error',
|
|
content: `Er ging iets mis: ${error}`,
|
|
});
|
|
}
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|