feat(cortex): implement activePatient fallback in various components
- 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.
This commit is contained in:
@@ -102,7 +102,7 @@ export function ChatEmptyState({ onSelectAction, activePatientName }: ChatEmptyS
|
||||
Welkom bij Cortex Assistent
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 max-w-md">
|
||||
Typ of spreek wat je wilt doen. Klik op een kaart om snel te starten.
|
||||
Jij zorgt. Ik regel de rest.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ interface ChatMessageProps {
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, showTimestamp = false }: ChatMessageProps) {
|
||||
// Nudge messages are handled by NudgeChatMessage component
|
||||
// This shouldn't be called for nudge type, but guard just in case
|
||||
if (message.type === 'nudge') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const styles = MESSAGE_STYLES[message.type];
|
||||
|
||||
// Don't show border for system messages
|
||||
|
||||
@@ -14,13 +14,14 @@ 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 } from '@/stores/cortex-store';
|
||||
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';
|
||||
@@ -48,9 +49,12 @@ export function ChatPanel() {
|
||||
const setPendingClarification = useCortexStore((s) => s.setPendingClarification);
|
||||
const resolveClarification = useCortexStore((s) => s.resolveClarification);
|
||||
|
||||
// Artifact & Nudge state (E5.S2)
|
||||
// Artifact state (E5.S2)
|
||||
const openArtifact = useCortexStore((s) => s.openArtifact);
|
||||
const addSuggestion = useCortexStore((s) => s.addSuggestion);
|
||||
|
||||
// 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);
|
||||
@@ -165,6 +169,7 @@ export function ChatPanel() {
|
||||
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,
|
||||
@@ -174,11 +179,18 @@ export function ChatPanel() {
|
||||
});
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
console.log('[ChatPanel] Nudge suggestions:', suggestions.length);
|
||||
suggestions.forEach((suggestion) => addSuggestion(suggestion));
|
||||
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, addSuggestion]);
|
||||
}, [activeChain, updateActionStatus, openArtifact, addChatMessage]);
|
||||
|
||||
const handleSkipAction = useCallback((actionId: string) => {
|
||||
console.log('[ChatPanel] Skipping action:', actionId);
|
||||
@@ -210,6 +222,35 @@ export function ChatPanel() {
|
||||
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;
|
||||
@@ -258,7 +299,16 @@ export function ChatPanel() {
|
||||
{hasMessages ? (
|
||||
<div className="flex flex-col space-y-3">
|
||||
{chatMessages.map((message) => (
|
||||
<ChatMessage key={message.id} message={message} showTimestamp />
|
||||
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 */}
|
||||
@@ -366,9 +416,18 @@ export function ChatPanel() {
|
||||
shift,
|
||||
},
|
||||
(chunk) => {
|
||||
// On each chunk, append to accumulated content and update last message
|
||||
// On each chunk, append to accumulated content
|
||||
accumulatedContent += chunk;
|
||||
updateLastMessage(accumulatedContent);
|
||||
|
||||
// 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
|
||||
|
||||
197
components/cortex/chat/nudge-chat-message.tsx
Normal file
197
components/cortex/chat/nudge-chat-message.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* NudgeChatMessage Component
|
||||
*
|
||||
* Displays a protocol-based nudge suggestion in the chat.
|
||||
* Shows protocol metadata, clinical rationale, and accept/dismiss buttons.
|
||||
*
|
||||
* Epic: E4 (Nudge)
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Lightbulb, BookOpen, X } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { NudgeSuggestion } from '@/lib/cortex/types';
|
||||
|
||||
interface NudgeChatMessageProps {
|
||||
suggestion: NudgeSuggestion;
|
||||
onAccept: (suggestionId: string) => void;
|
||||
onDismiss: (suggestionId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Priority-based styling for the nudge message
|
||||
*/
|
||||
const PRIORITY_STYLES = {
|
||||
low: {
|
||||
container: 'bg-slate-50 border-slate-200',
|
||||
badge: 'bg-slate-100 text-slate-700',
|
||||
text: 'text-slate-700',
|
||||
icon: 'text-slate-500',
|
||||
progress: 'bg-slate-300',
|
||||
},
|
||||
medium: {
|
||||
container: 'bg-amber-50 border-amber-200',
|
||||
badge: 'bg-amber-100 text-amber-800',
|
||||
text: 'text-amber-900',
|
||||
icon: 'text-amber-600',
|
||||
progress: 'bg-amber-400',
|
||||
},
|
||||
high: {
|
||||
container: 'bg-red-50 border-red-200',
|
||||
badge: 'bg-red-100 text-red-800',
|
||||
text: 'text-red-900',
|
||||
icon: 'text-red-600',
|
||||
progress: 'bg-red-400',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get button text based on suggested intent
|
||||
*/
|
||||
function getAcceptButtonText(intent: string): string {
|
||||
switch (intent) {
|
||||
case 'create_appointment':
|
||||
return 'Ja, inplannen';
|
||||
case 'dagnotitie':
|
||||
return 'Ja, notitie maken';
|
||||
case 'cancel_appointment':
|
||||
return 'Ja, annuleren';
|
||||
default:
|
||||
return 'Ja, uitvoeren';
|
||||
}
|
||||
}
|
||||
|
||||
export function NudgeChatMessage({
|
||||
suggestion,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
}: NudgeChatMessageProps) {
|
||||
const [progress, setProgress] = useState(100);
|
||||
const styles = PRIORITY_STYLES[suggestion.priority];
|
||||
const protocol = suggestion.suggestion.protocol;
|
||||
|
||||
// Memoize dismiss handler
|
||||
const handleDismiss = useCallback(() => {
|
||||
onDismiss(suggestion.id);
|
||||
}, [onDismiss, suggestion.id]);
|
||||
|
||||
// Countdown effect
|
||||
useEffect(() => {
|
||||
if (!suggestion.expiresAt) return;
|
||||
|
||||
const expiresAt = new Date(suggestion.expiresAt).getTime();
|
||||
const createdAt = new Date(suggestion.createdAt).getTime();
|
||||
const total = expiresAt - createdAt;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const remaining = expiresAt - now;
|
||||
|
||||
if (remaining <= 0) {
|
||||
handleDismiss();
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress((remaining / total) * 100);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [suggestion.expiresAt, suggestion.createdAt, handleDismiss]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -5, scale: 0.98 }}
|
||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className={cn(
|
||||
'self-start max-w-[90%] rounded-2xl rounded-tl-sm border p-4',
|
||||
styles.container
|
||||
)}
|
||||
>
|
||||
{/* Header with icon and dismiss */}
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb className={cn('w-5 h-5', styles.icon)} />
|
||||
<span className={cn('text-sm font-medium', styles.text)}>
|
||||
Protocol Suggestie
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className={cn(
|
||||
'p-1 rounded-md hover:bg-black/5 transition-colors',
|
||||
styles.text
|
||||
)}
|
||||
aria-label="Sluiten"
|
||||
>
|
||||
<X className="w-4 h-4 opacity-50 hover:opacity-100" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Protocol badge */}
|
||||
{protocol && (
|
||||
<div className="mb-3">
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium',
|
||||
styles.badge
|
||||
)}
|
||||
>
|
||||
<BookOpen className="w-3.5 h-3.5" />
|
||||
<span>{protocol.name}</span>
|
||||
{protocol.reference && (
|
||||
<span className="opacity-75">{protocol.reference}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestion message */}
|
||||
<p className={cn('font-medium mb-2', styles.text)}>
|
||||
{suggestion.suggestion.message}
|
||||
</p>
|
||||
|
||||
{/* Clinical rationale */}
|
||||
{protocol?.rationale && (
|
||||
<p className={cn('text-sm opacity-80 mb-4', styles.text)}>
|
||||
{protocol.rationale}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onAccept(suggestion.id)}
|
||||
className="bg-teal-600 hover:bg-teal-700 text-white"
|
||||
>
|
||||
{getAcceptButtonText(suggestion.suggestion.intent)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleDismiss}
|
||||
className={styles.text}
|
||||
>
|
||||
Later
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Countdown progress bar */}
|
||||
<div className="mt-3 h-1 bg-white/50 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className={cn('h-full rounded-full', styles.progress)}
|
||||
initial={{ width: '100%' }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 1, ease: 'linear' }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user