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 */} -