From 05836c5c6ae4b5aaba04af6a2148a6d6d92f1c21 Mon Sep 17 00:00:00 2001 From: colinislit Date: Sat, 3 Jan 2026 22:18:30 +0100 Subject: [PATCH] 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. --- app/login/components/login-form.tsx | 2 +- .../cortex/artifacts/artifact-container.tsx | 6 +- components/cortex/blocks/dagnotitie-block.tsx | 50 +++-- .../cortex/blocks/patient-dashboard-block.tsx | 13 +- components/cortex/chat/chat-empty-state.tsx | 2 +- components/cortex/chat/chat-message.tsx | 6 + components/cortex/chat/chat-panel.tsx | 77 ++++++- components/cortex/chat/nudge-chat-message.tsx | 197 ++++++++++++++++++ .../cortex/command-center/command-center.tsx | 50 +++-- .../patient-sidebar/patient-sidebar.tsx | 15 +- .../cortex/shared/patient-list-item.tsx | 24 +-- .../nl/documentatie/release-notes-system.mdx | 1 + .../bouwplan-patient-selectie-v1.md | 31 +-- lib/cortex/hooks/use-patient-selection.ts | 33 ++- lib/cortex/nudge.ts | 9 + lib/cortex/types.ts | 12 ++ lib/fhir/patient-mapper.ts | 33 +++ stores/cortex-store.ts | 4 +- 18 files changed, 472 insertions(+), 93 deletions(-) create mode 100644 components/cortex/chat/nudge-chat-message.tsx diff --git a/app/login/components/login-form.tsx b/app/login/components/login-form.tsx index 31ada5b..8c78016 100644 --- a/app/login/components/login-form.tsx +++ b/app/login/components/login-form.tsx @@ -169,7 +169,7 @@ export function LoginForm() {
Cortex
-
Spraak & AI
+
Je EPD dat meedenkt
diff --git a/components/cortex/artifacts/artifact-container.tsx b/components/cortex/artifacts/artifact-container.tsx index 5550102..ef88fd6 100644 --- a/components/cortex/artifacts/artifact-container.tsx +++ b/components/cortex/artifacts/artifact-container.tsx @@ -284,7 +284,7 @@ export function ArtifactContainer({ {artifacts.length > 1 && ( -
+
{artifacts.map((artifact) => ( +
{activeArtifact ? ( -
+
{renderArtifactBlock(activeArtifact, onCloseArtifact)}
) : ( diff --git a/components/cortex/blocks/dagnotitie-block.tsx b/components/cortex/blocks/dagnotitie-block.tsx index 8c681ed..830b895 100644 --- a/components/cortex/blocks/dagnotitie-block.tsx +++ b/components/cortex/blocks/dagnotitie-block.tsx @@ -26,6 +26,7 @@ import { Loader2, Search, User, RefreshCw } from 'lucide-react'; import { cn } from '@/lib/utils'; import { safeFetch, getErrorInfo, retryFetch } from '@/lib/cortex/error-handler'; import { evaluateNudge } from '@/lib/cortex/nudge'; +import { formatPatientName as formatPatientNameFromDb } from '@/lib/fhir/patient-mapper'; interface DagnotitieBlockProps { prefill?: BlockPrefillData; @@ -40,20 +41,25 @@ interface Patient { export function DagnotatieBlock({ prefill }: DagnotitieBlockProps) { const config = BLOCK_CONFIGS.dagnotitie; - const { closeBlock, addSuggestion } = useCortexStore(); + const { closeBlock, addChatMessage, activePatient } = useCortexStore(); const { toast } = useToast(); - // Form state - const [patientId, setPatientId] = useState(prefill?.patientId || ''); - const [patientName, setPatientName] = useState(prefill?.patientName || ''); + // E3.S1: Determine initial patient from prefill OR activePatient + const initialPatientId = prefill?.patientId || activePatient?.id || ''; + const initialPatientName = prefill?.patientName || (activePatient ? formatPatientNameFromDb(activePatient) : ''); + const hasPrefillPatient = Boolean(prefill?.patientId); + + // Form state - E3.S1: Use activePatient as fallback + const [patientId, setPatientId] = useState(initialPatientId); + const [patientName, setPatientName] = useState(initialPatientName); const [category, setCategory] = useState( prefill?.category || 'observatie' ); const [content, setContent] = useState(prefill?.content || ''); const [includeInHandover, setIncludeInHandover] = useState(false); - // Patient search state - const [searchQuery, setSearchQuery] = useState(prefill?.patientName || ''); + // Patient search state - E3.S1: Use activePatient as fallback + const [searchQuery, setSearchQuery] = useState(initialPatientName); const [patients, setPatients] = useState([]); const [isSearching, setIsSearching] = useState(false); const [showPatientDropdown, setShowPatientDropdown] = useState(false); @@ -61,18 +67,33 @@ export function DagnotatieBlock({ prefill }: DagnotitieBlockProps) { const searchTimeoutRef = useRef(); const dropdownRef = useRef(null); - // Prefill patient if patientId is provided + // E3.S1: Prefill patient from prefill OR activePatient useEffect(() => { + // Priority 1: Explicit prefill if (prefill?.patientId && prefill?.patientName) { setPatientId(prefill.patientId); setPatientName(prefill.patientName); + setSearchQuery(prefill.patientName); setSelectedPatient({ id: prefill.patientId, name_family: prefill.patientName.split(' ').pop(), name_given: prefill.patientName.split(' ').slice(0, -1), }); } - }, [prefill]); + // Priority 2: activePatient (only if no prefill patient) + else if (!hasPrefillPatient && activePatient) { + const name = formatPatientNameFromDb(activePatient); + setPatientId(activePatient.id); + setPatientName(name); + setSearchQuery(name); + setSelectedPatient({ + id: activePatient.id, + name_family: activePatient.name_family || undefined, + name_given: activePatient.name_given || [], + identifier_bsn: activePatient.identifier_bsn || undefined, + }); + } + }, [prefill, activePatient, hasPrefillPatient]); // Patient search with debouncing const searchPatients = useCallback(async (query: string) => { @@ -223,6 +244,7 @@ export function DagnotatieBlock({ prefill }: DagnotitieBlockProps) { }); // E4: Evaluate nudge after successful save + // Now adds nudges as chat messages instead of toast const nudges = evaluateNudge({ intent: 'dagnotitie', actionId: data.id || `dagnotitie-${Date.now()}`, @@ -233,10 +255,14 @@ export function DagnotatieBlock({ prefill }: DagnotitieBlockProps) { content: content.trim(), }); - // Add nudge suggestions to store + // Add nudge suggestions as chat messages nudges.forEach((nudge) => { - addSuggestion(nudge); - console.log('[DagnotatieBlock] Nudge triggered:', nudge.suggestion.message); + addChatMessage({ + type: 'nudge', + content: nudge.suggestion.message, + nudge: nudge, + }); + console.log('[DagnotatieBlock] Nudge chat message:', nudge.suggestion.message); }); // Close block after short delay @@ -259,7 +285,7 @@ export function DagnotatieBlock({ prefill }: DagnotitieBlockProps) { } finally { setIsSubmitting(false); } - }, [patientId, content, category, includeInHandover, patientName, toast, closeBlock, addSuggestion]); + }, [patientId, content, category, includeInHandover, patientName, toast, closeBlock, addChatMessage]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/components/cortex/blocks/patient-dashboard-block.tsx b/components/cortex/blocks/patient-dashboard-block.tsx index abb8b06..dc4b513 100644 --- a/components/cortex/blocks/patient-dashboard-block.tsx +++ b/components/cortex/blocks/patient-dashboard-block.tsx @@ -23,9 +23,11 @@ import { safeFetch, getErrorInfo } from '@/lib/cortex/error-handler'; import { useToast } from '@/hooks/use-toast'; import { BLOCK_CONFIGS } from '@/lib/cortex/types'; import type { BlockPrefillData } from '@/stores/cortex-store'; +import { useCortexStore } from '@/stores/cortex-store'; import type { FHIRPatient } from '@/lib/fhir'; import type { Intake } from '@/lib/types/intake'; import { cn } from '@/lib/utils'; +import { formatPatientName } from '@/lib/fhir/patient-mapper'; interface PatientDashboardBlockProps { prefill?: BlockPrefillData; @@ -103,9 +105,13 @@ function getPatientBsn(patient?: FHIRPatient): string | null { export function PatientDashboardBlock({ prefill }: PatientDashboardBlockProps) { const config = BLOCK_CONFIGS['patient-dashboard']; - const patientId = prefill?.patientId; + const { activePatient } = useCortexStore(); const { toast } = useToast(); + // E3.S2: Use activePatient as fallback for patientId + const patientId = prefill?.patientId || activePatient?.id; + const patientNameFromPrefill = prefill?.patientName || (activePatient ? formatPatientName(activePatient) : undefined); + const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(Boolean(patientId)); const [error, setError] = useState(null); @@ -178,8 +184,9 @@ export function PatientDashboardBlock({ prefill }: PatientDashboardBlockProps) { ? data?.carePlan?.activities.length : 0; - const title = prefill?.patientName - ? `${config.title} - ${prefill.patientName}` + // E3.S2: Use patientNameFromPrefill for title + const title = patientNameFromPrefill + ? `${config.title} - ${patientNameFromPrefill}` : config.title; return ( diff --git a/components/cortex/chat/chat-empty-state.tsx b/components/cortex/chat/chat-empty-state.tsx index 10208ed..a74b081 100644 --- a/components/cortex/chat/chat-empty-state.tsx +++ b/components/cortex/chat/chat-empty-state.tsx @@ -102,7 +102,7 @@ export function ChatEmptyState({ onSelectAction, activePatientName }: ChatEmptyS Welkom bij Cortex Assistent

- Typ of spreek wat je wilt doen. Klik op een kaart om snel te starten. + Jij zorgt. Ik regel de rest.

diff --git a/components/cortex/chat/chat-message.tsx b/components/cortex/chat/chat-message.tsx index 5c05e96..8fa537a 100644 --- a/components/cortex/chat/chat-message.tsx +++ b/components/cortex/chat/chat-message.tsx @@ -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 diff --git a/components/cortex/chat/chat-panel.tsx b/components/cortex/chat/chat-panel.tsx index d5081ef..a236892 100644 --- a/components/cortex/chat/chat-panel.tsx +++ b/components/cortex/chat/chat-panel.tsx @@ -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(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 ? (
{chatMessages.map((message) => ( - + message.type === 'nudge' && message.nudge ? ( + handleAcceptNudge(id, message.nudge)} + onDismiss={handleDismissNudge} + /> + ) : ( + + ) ))} {/* 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 diff --git a/components/cortex/chat/nudge-chat-message.tsx b/components/cortex/chat/nudge-chat-message.tsx new file mode 100644 index 0000000..a2bf0f5 --- /dev/null +++ b/components/cortex/chat/nudge-chat-message.tsx @@ -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 ( + + {/* Header with icon and dismiss */} +
+
+ + + Protocol Suggestie + +
+ +
+ + {/* Protocol badge */} + {protocol && ( +
+
+ + {protocol.name} + {protocol.reference && ( + {protocol.reference} + )} +
+
+ )} + + {/* Suggestion message */} +

+ {suggestion.suggestion.message} +

+ + {/* Clinical rationale */} + {protocol?.rationale && ( +

+ {protocol.rationale} +

+ )} + + {/* Action buttons */} +
+ + +
+ + {/* Countdown progress bar */} +
+ +
+
+ ); +} diff --git a/components/cortex/command-center/command-center.tsx b/components/cortex/command-center/command-center.tsx index 4c751c0..6bdc6a5 100644 --- a/components/cortex/command-center/command-center.tsx +++ b/components/cortex/command-center/command-center.tsx @@ -20,6 +20,8 @@ import { useEffect, useCallback, useRef } from 'react'; import { AnimatePresence } from 'framer-motion'; import { useCortexStore } from '@/stores/cortex-store'; import { cn } from '@/lib/utils'; +import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; +import { useMediaQuery } from '@/hooks/use-media-query'; import { ContextBar } from './context-bar'; import { OfflineBanner } from './offline-banner'; import { NudgeToast } from './nudge-toast'; @@ -143,20 +145,42 @@ export function CommandCenter() { {/* Split-screen container - flex-1 */}
- {/* Chat Panel - 40% (desktop), 100% (mobile) */} -
- -
+ {/* Mobile View / Desktop Resizable View Toggle */} + + {/* Chat Panel Panel */} + + + - {/* Artifact Area - 60% (desktop), overlay on mobile */} -