feat(cortex): Epic 3 - UI Components complete
Epic 3 delivers the visual layer for multi-intent flows and clarification: E3.S1 - ActionChainCard (3 SP) - Container for multi-intent chain display - Header with AI/Local source badge and action count - Original user input context - Stacked ActionItems with Framer Motion animations - Collapsible AI reasoning section - Status-dependent styling (pending/executing/completed/partial/failed) E3.S2 - ActionItem (2 SP) - 6 status icons (pending, confirming, executing, success, failed, skipped) - Dutch intent labels via shared intent-labels.ts - Confidence badge with color coding (green ≥90%, amber ≥70%, red <70%) - Entity summary formatting - Confirmation buttons for confirming status - Retry button for failed + recoverable actions E3.S3 - ClarificationCard (2 SP) - Amber-themed styling for question context - Header with HelpCircle icon - Original user input display - Responsive options grid (2 cols desktop, 1 col mobile) - Dismiss button and cancel link - Framer Motion entry/exit animations E3.S4 - ProcessingIndicator (1 SP) - 3 variants: spinner, skeleton, pulse - 3 sizes: sm, md, lg - Configurable message - InlineSpinner export for buttons Integration in ChatPanel (v4.0): - V2 store hooks for activeChain, pendingClarification - Event handlers for confirm/skip/retry/dismiss - Conditional rendering with AnimatePresence New files: - lib/cortex/intent-labels.ts - components/cortex/chat/action-chain-card.tsx - components/cortex/chat/action-item.tsx - components/cortex/chat/clarification-card.tsx - components/cortex/chat/processing-indicator.tsx Modified: - components/cortex/chat/chat-panel.tsx (v3.0 → v4.0) - docs/intent/bouwplan-cortex-v2.md (v1.2 → v1.3) Progress: 35/48 SP (73%) - E0, E1, E2, E3 complete 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
229
components/cortex/chat/action-chain-card.tsx
Normal file
229
components/cortex/chat/action-chain-card.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* ActionChainCard Component
|
||||
*
|
||||
* Container for displaying multi-intent action chains with:
|
||||
* - Header showing source (AI/local) and action count
|
||||
* - Original user input
|
||||
* - Stacked ActionItem components for each action
|
||||
* - Collapsible AI reasoning section
|
||||
* - Dismiss button
|
||||
*
|
||||
* Epic: E3 (UI Components)
|
||||
* Story: E3.S1 (ActionChainCard component)
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Sparkles, ChevronDown, ChevronUp, X, Cpu } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IntentChain } from '@/lib/cortex/types';
|
||||
import { ActionItem } from './action-item';
|
||||
|
||||
interface ActionChainCardProps {
|
||||
chain: IntentChain;
|
||||
onConfirmAction: (actionId: string) => void;
|
||||
onSkipAction: (actionId: string) => void;
|
||||
onRetryAction: (actionId: string) => void;
|
||||
onDismissChain: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animation variants for the card container
|
||||
*/
|
||||
const containerVariants = {
|
||||
initial: { opacity: 0, y: 20, scale: 0.95 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 0.2, ease: [0.4, 0, 0.2, 1] as const },
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -10,
|
||||
scale: 0.98,
|
||||
transition: { duration: 0.15 },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Stagger animation for action items
|
||||
*/
|
||||
const listVariants = {
|
||||
animate: {
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
initial: { opacity: 0, x: -10 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: { duration: 0.15 },
|
||||
},
|
||||
};
|
||||
|
||||
export function ActionChainCard({
|
||||
chain,
|
||||
onConfirmAction,
|
||||
onSkipAction,
|
||||
onRetryAction,
|
||||
onDismissChain,
|
||||
}: ActionChainCardProps) {
|
||||
const [showReasoning, setShowReasoning] = useState(false);
|
||||
|
||||
const actionCount = chain.actions.length;
|
||||
const completedCount = chain.actions.filter(
|
||||
(a) => a.status === 'success' || a.status === 'skipped'
|
||||
).length;
|
||||
const isFromAI = chain.meta.source === 'ai';
|
||||
const hasReasoning = !!chain.meta.aiReasoning;
|
||||
|
||||
// Determine overall chain state for styling
|
||||
const getChainStatusStyle = () => {
|
||||
switch (chain.status) {
|
||||
case 'executing':
|
||||
return 'border-blue-300 bg-blue-50/50';
|
||||
case 'completed':
|
||||
return 'border-green-300 bg-green-50/50';
|
||||
case 'partial':
|
||||
return 'border-amber-300 bg-amber-50/50';
|
||||
case 'failed':
|
||||
return 'border-red-300 bg-red-50/50';
|
||||
default:
|
||||
return 'border-slate-200 bg-white';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
className={cn(
|
||||
'rounded-xl border shadow-sm overflow-hidden',
|
||||
getChainStatusStyle()
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200 bg-white/80">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Source badge */}
|
||||
{isFromAI ? (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-amber-100 text-amber-700">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span className="text-xs font-medium">AI</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-slate-100 text-slate-600">
|
||||
<Cpu className="w-3.5 h-3.5" />
|
||||
<span className="text-xs font-medium">Lokaal</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action count */}
|
||||
<span className="text-sm font-medium text-slate-900">
|
||||
{actionCount} {actionCount === 1 ? 'actie' : 'acties'} gedetecteerd
|
||||
</span>
|
||||
|
||||
{/* Progress indicator */}
|
||||
{chain.status === 'executing' && (
|
||||
<span className="text-xs text-slate-500">
|
||||
({completedCount}/{actionCount} voltooid)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDismissChain}
|
||||
className="h-7 w-7 p-0 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Original input */}
|
||||
<div className="px-4 py-2 bg-slate-50 border-b border-slate-100">
|
||||
<p className="text-sm text-slate-600 italic">
|
||||
“{chain.originalInput}”
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action items */}
|
||||
<motion.div
|
||||
variants={listVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="p-4 space-y-2"
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{chain.actions.map((action, index) => (
|
||||
<motion.div key={action.id} variants={itemVariants} layout>
|
||||
<ActionItem
|
||||
action={action}
|
||||
sequence={index + 1}
|
||||
totalActions={actionCount}
|
||||
onConfirm={() => onConfirmAction(action.id)}
|
||||
onSkip={() => onSkipAction(action.id)}
|
||||
onRetry={() => onRetryAction(action.id)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* AI Reasoning (collapsible) */}
|
||||
{hasReasoning && (
|
||||
<div className="border-t border-slate-200">
|
||||
<button
|
||||
onClick={() => setShowReasoning(!showReasoning)}
|
||||
className="w-full flex items-center justify-between px-4 py-2 text-sm text-slate-500 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
AI Redenering
|
||||
</span>
|
||||
{showReasoning ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showReasoning && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-4 py-3 bg-slate-50 text-sm text-slate-600">
|
||||
{chain.meta.aiReasoning}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Processing time footer */}
|
||||
<div className="px-4 py-2 border-t border-slate-100 bg-slate-50/50">
|
||||
<p className="text-xs text-slate-400">
|
||||
Verwerkt in {chain.meta.processingTimeMs}ms
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
166
components/cortex/chat/action-item.tsx
Normal file
166
components/cortex/chat/action-item.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* ActionItem Component
|
||||
*
|
||||
* Displays a single action in a multi-intent chain with:
|
||||
* - Sequence number badge
|
||||
* - Status icon (pending, confirming, executing, success, failed, skipped)
|
||||
* - Intent label (Dutch)
|
||||
* - Confidence badge with color coding
|
||||
* - Entity summary
|
||||
* - Confirmation/retry buttons
|
||||
*
|
||||
* Epic: E3 (UI Components)
|
||||
* Story: E3.S2 (ActionItem sub-component)
|
||||
*/
|
||||
|
||||
import {
|
||||
Circle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Check,
|
||||
X,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IntentAction, IntentActionStatus } from '@/lib/cortex/types';
|
||||
import {
|
||||
INTENT_LABELS,
|
||||
STATUS_STYLES,
|
||||
getConfidenceStyle,
|
||||
formatEntitySummary,
|
||||
} from '@/lib/cortex/intent-labels';
|
||||
|
||||
interface ActionItemProps {
|
||||
action: IntentAction;
|
||||
sequence: number;
|
||||
totalActions: number;
|
||||
onConfirm: () => void;
|
||||
onSkip: () => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status icons mapped to each action status
|
||||
*/
|
||||
const STATUS_ICONS: Record<IntentActionStatus, React.ReactNode> = {
|
||||
pending: <Circle className="w-4 h-4" />,
|
||||
confirming: <AlertCircle className="w-4 h-4" />,
|
||||
executing: <Loader2 className="w-4 h-4 animate-spin" />,
|
||||
success: <Check className="w-4 h-4" />,
|
||||
failed: <X className="w-4 h-4" />,
|
||||
skipped: <X className="w-4 h-4" />,
|
||||
};
|
||||
|
||||
export function ActionItem({
|
||||
action,
|
||||
sequence,
|
||||
totalActions,
|
||||
onConfirm,
|
||||
onSkip,
|
||||
onRetry,
|
||||
}: ActionItemProps) {
|
||||
const statusStyle = STATUS_STYLES[action.status];
|
||||
const confidenceStyle = getConfidenceStyle(action.confidence);
|
||||
const intentLabel = INTENT_LABELS[action.intent] || action.intent;
|
||||
const entitySummary = formatEntitySummary(action.intent, action.entities);
|
||||
|
||||
const isConfirming = action.status === 'confirming';
|
||||
const isFailed = action.status === 'failed';
|
||||
const isCompleted = action.status === 'success' || action.status === 'skipped';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-3 transition-colors',
|
||||
statusStyle.bg,
|
||||
statusStyle.border
|
||||
)}
|
||||
>
|
||||
{/* Main row: sequence, icon, label, confidence */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Sequence badge */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center',
|
||||
'text-xs font-medium',
|
||||
isCompleted ? 'bg-slate-200 text-slate-500' : 'bg-slate-100 text-slate-700'
|
||||
)}
|
||||
>
|
||||
{sequence}
|
||||
</div>
|
||||
|
||||
{/* Status icon */}
|
||||
<div className={cn('flex-shrink-0', statusStyle.icon)}>
|
||||
{STATUS_ICONS[action.status]}
|
||||
</div>
|
||||
|
||||
{/* Intent label */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={cn('text-sm font-medium', statusStyle.text)}>
|
||||
{intentLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Confidence badge */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-shrink-0 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
confidenceStyle.bg,
|
||||
confidenceStyle.text
|
||||
)}
|
||||
>
|
||||
{Math.round(action.confidence * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entity summary */}
|
||||
<div className="mt-2 ml-9 text-sm text-slate-600">{entitySummary}</div>
|
||||
|
||||
{/* Confirmation message and buttons */}
|
||||
{isConfirming && (
|
||||
<div className="mt-3 ml-9 space-y-2">
|
||||
{action.confirmationMessage && (
|
||||
<p className="text-sm text-amber-800">{action.confirmationMessage}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={onConfirm}
|
||||
className="bg-teal-600 hover:bg-teal-700"
|
||||
>
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
Bevestigen
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onSkip}>
|
||||
Overslaan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state with retry option */}
|
||||
{isFailed && action.error && (
|
||||
<div className="mt-3 ml-9 space-y-2">
|
||||
<p className="text-sm text-red-700">{action.error.message}</p>
|
||||
{action.error.recoverable && (
|
||||
<Button size="sm" variant="outline" onClick={onRetry}>
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
Opnieuw proberen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completion time (optional, for completed actions) */}
|
||||
{isCompleted && action.completedAt && (
|
||||
<div className="mt-1 ml-9 text-xs text-slate-400">
|
||||
{action.status === 'success' ? 'Voltooid' : 'Overgeslagen'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,30 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Chat Panel (v3.0)
|
||||
* 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)
|
||||
* Story: E2.S3 (ChatPanel component - scrolling)
|
||||
* 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 { ChatInput, ChatInputHandle } from './chat-input';
|
||||
import { ActionChainCard } from './action-chain-card';
|
||||
import { ClarificationCard } from './clarification-card';
|
||||
import { ProcessingIndicator } from './processing-indicator';
|
||||
import { useCortexStore } from '@/stores/cortex-store';
|
||||
import { sendChatMessage } from '@/lib/cortex/chat-api';
|
||||
import { parseActionFromResponse, shouldOpenArtifact } from '@/lib/cortex/action-parser';
|
||||
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);
|
||||
@@ -28,6 +34,16 @@ export function ChatPanel() {
|
||||
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);
|
||||
|
||||
// Refs for scrolling
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
@@ -87,6 +103,50 @@ export function ChatPanel() {
|
||||
return () => window.removeEventListener('keydown', handleGlobalKeyDown);
|
||||
}, []);
|
||||
|
||||
// V2 Chain action handlers
|
||||
const handleConfirmAction = useCallback((actionId: string) => {
|
||||
console.log('[ChatPanel] Confirming action:', actionId);
|
||||
updateActionStatus(actionId, 'executing');
|
||||
// TODO: E5.S2 - Execute the actual action via API
|
||||
// For now, simulate success after a short delay
|
||||
setTimeout(() => {
|
||||
updateActionStatus(actionId, 'success');
|
||||
}, 500);
|
||||
}, [updateActionStatus]);
|
||||
|
||||
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]);
|
||||
|
||||
// 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 */}
|
||||
@@ -100,6 +160,42 @@ export function ChatPanel() {
|
||||
{chatMessages.map((message) => (
|
||||
<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 */}
|
||||
<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 */}
|
||||
<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>
|
||||
|
||||
133
components/cortex/chat/clarification-card.tsx
Normal file
133
components/cortex/chat/clarification-card.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* ClarificationCard Component
|
||||
*
|
||||
* Displays clarification questions from the AI when user input is ambiguous.
|
||||
* Shows:
|
||||
* - Question text
|
||||
* - Multiple choice options as buttons
|
||||
* - Original input for context
|
||||
* - Dismiss option
|
||||
*
|
||||
* Epic: E3 (UI Components)
|
||||
* Story: E3.S3 (ClarificationCard component)
|
||||
*/
|
||||
|
||||
import { HelpCircle, X } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ClarificationCardProps {
|
||||
question: string;
|
||||
options: string[];
|
||||
originalInput: string;
|
||||
onSelectOption: (option: string) => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animation variants for the card
|
||||
*/
|
||||
const cardVariants = {
|
||||
initial: { opacity: 0, y: 10, scale: 0.98 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 0.2, ease: [0.4, 0, 0.2, 1] as const },
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -5,
|
||||
scale: 0.98,
|
||||
transition: { duration: 0.15 },
|
||||
},
|
||||
};
|
||||
|
||||
export function ClarificationCard({
|
||||
question,
|
||||
options,
|
||||
originalInput,
|
||||
onSelectOption,
|
||||
onDismiss,
|
||||
}: ClarificationCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
variants={cardVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
className="rounded-xl border border-amber-200 bg-amber-50 shadow-sm overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-amber-200 bg-amber-100/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-amber-200">
|
||||
<HelpCircle className="w-4 h-4 text-amber-700" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-amber-900">
|
||||
Verduidelijking nodig
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDismiss}
|
||||
className="h-7 w-7 p-0 text-amber-600 hover:text-amber-800 hover:bg-amber-200"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Original input context */}
|
||||
<div className="text-xs text-amber-700">
|
||||
<span className="font-medium">Je zei:</span>{' '}
|
||||
<span className="italic">“{originalInput}”</span>
|
||||
</div>
|
||||
|
||||
{/* Question */}
|
||||
<p className="text-sm font-medium text-amber-900">{question}</p>
|
||||
|
||||
{/* Options grid */}
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-2',
|
||||
options.length <= 2 ? 'grid-cols-2' : 'grid-cols-1 sm:grid-cols-2'
|
||||
)}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
onClick={() => onSelectOption(option)}
|
||||
className={cn(
|
||||
'justify-start h-auto py-2.5 px-3',
|
||||
'border-amber-300 bg-white hover:bg-amber-100 hover:border-amber-400',
|
||||
'text-amber-900 text-sm font-medium',
|
||||
'transition-colors'
|
||||
)}
|
||||
>
|
||||
{option}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cancel link */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="text-xs text-amber-600 hover:text-amber-800 underline underline-offset-2"
|
||||
>
|
||||
Annuleren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
151
components/cortex/chat/processing-indicator.tsx
Normal file
151
components/cortex/chat/processing-indicator.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* ProcessingIndicator Component
|
||||
*
|
||||
* Shows loading state during AI operations with multiple variants:
|
||||
* - spinner: Rotating loader icon with text (default)
|
||||
* - skeleton: Pulsing placeholder blocks
|
||||
* - pulse: Animated dots
|
||||
*
|
||||
* Epic: E3 (UI Components)
|
||||
* Story: E3.S4 (Processing indicator)
|
||||
*/
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ProcessingIndicatorProps {
|
||||
/** Loading message to display (default: "Even nadenken...") */
|
||||
message?: string;
|
||||
/** Visual style variant */
|
||||
type?: 'spinner' | 'skeleton' | 'pulse';
|
||||
/** Size of the indicator */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Size configurations for each variant
|
||||
*/
|
||||
const SIZE_CONFIG = {
|
||||
sm: {
|
||||
icon: 'w-3 h-3',
|
||||
text: 'text-xs',
|
||||
dot: 'w-1.5 h-1.5',
|
||||
skeleton: 'h-3',
|
||||
},
|
||||
md: {
|
||||
icon: 'w-4 h-4',
|
||||
text: 'text-sm',
|
||||
dot: 'w-2 h-2',
|
||||
skeleton: 'h-4',
|
||||
},
|
||||
lg: {
|
||||
icon: 'w-5 h-5',
|
||||
text: 'text-base',
|
||||
dot: 'w-2.5 h-2.5',
|
||||
skeleton: 'h-5',
|
||||
},
|
||||
};
|
||||
|
||||
export function ProcessingIndicator({
|
||||
message = 'Even nadenken...',
|
||||
type = 'spinner',
|
||||
size = 'md',
|
||||
className,
|
||||
}: ProcessingIndicatorProps) {
|
||||
const sizeConfig = SIZE_CONFIG[size];
|
||||
|
||||
// Spinner variant - rotating icon with text
|
||||
if (type === 'spinner') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-slate-500',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Loader2 className={cn(sizeConfig.icon, 'animate-spin')} />
|
||||
<span className={sizeConfig.text}>{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton variant - pulsing placeholder blocks
|
||||
if (type === 'skeleton') {
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
sizeConfig.skeleton,
|
||||
'bg-slate-200 rounded animate-pulse w-3/4'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
sizeConfig.skeleton,
|
||||
'bg-slate-200 rounded animate-pulse w-1/2'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
sizeConfig.skeleton,
|
||||
'bg-slate-200 rounded animate-pulse w-2/3'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Pulse variant - animated dots
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
sizeConfig.dot,
|
||||
'bg-blue-500 rounded-full animate-pulse'
|
||||
)}
|
||||
style={{ animationDelay: '0ms' }}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
sizeConfig.dot,
|
||||
'bg-blue-500 rounded-full animate-pulse'
|
||||
)}
|
||||
style={{ animationDelay: '150ms' }}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
sizeConfig.dot,
|
||||
'bg-blue-500 rounded-full animate-pulse'
|
||||
)}
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
{message && (
|
||||
<span className={cn(sizeConfig.text, 'text-slate-500 ml-2')}>
|
||||
{message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline spinner for use in buttons or compact spaces
|
||||
*/
|
||||
export function InlineSpinner({
|
||||
size = 'sm',
|
||||
className,
|
||||
}: {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}) {
|
||||
const sizeConfig = SIZE_CONFIG[size];
|
||||
return (
|
||||
<Loader2
|
||||
className={cn(sizeConfig.icon, 'animate-spin text-current', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# Bouwplan — Cortex Intent System V2
|
||||
|
||||
**Projectnaam:** Cortex V2 - Agentic Intent Architecture
|
||||
**Versie:** v1.2
|
||||
**Versie:** v1.3
|
||||
**Datum:** 01-01-2026
|
||||
**Auteur:** Colin Lit
|
||||
|
||||
@@ -134,7 +134,7 @@ lib/config/
|
||||
| **E0** | Foundation & Context | Types, API, feature flags | ✅ Done | 5 | 8 SP |
|
||||
| **E1** | Reflex Arc (Layer 1) | Snelle lokale classificatie | ✅ Done | 4 | 6 SP |
|
||||
| **E2** | Intent Orchestrator (Layer 2) | AI-gedreven multi-intent | ✅ Done | 6 | 13 SP |
|
||||
| **E3** | UI Components | ActionChainCard, ClarificationCard | ⏳ To Do | 4 | 8 SP |
|
||||
| **E3** | UI Components | ActionChainCard, ClarificationCard | ✅ Done | 4 | 8 SP |
|
||||
| **E4** | Nudge MVP (Layer 3) | Proactieve suggesties | ⏳ To Do | 3 | 5 SP |
|
||||
| **E5** | Integration & Polish | End-to-end flow, testing | ⏳ To Do | 4 | 8 SP |
|
||||
|
||||
@@ -679,10 +679,10 @@ try {
|
||||
|
||||
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | SP |
|
||||
|----------|--------------|---------------------|--------|------------------|----|
|
||||
| E3.S1 | **ActionChainCard** component | Stacked cards met sequence, status, confidence | ⏳ | E0.S1 | 3 |
|
||||
| E3.S2 | **ActionItem** sub-component | Status icons, confirmation buttons, error states | ⏳ | E3.S1 | 2 |
|
||||
| E3.S3 | **ClarificationCard** component | Vraag + keuze-knoppen bij ambigue input | ⏳ | — | 2 |
|
||||
| E3.S4 | **Processing indicator** | Spinner/skeleton bij AI-acties | ⏳ | — | 1 |
|
||||
| E3.S1 | **ActionChainCard** component | Stacked cards met sequence, status, confidence | ✅ | E0.S1 | 3 |
|
||||
| E3.S2 | **ActionItem** sub-component | Status icons, confirmation buttons, error states | ✅ | E3.S1 | 2 |
|
||||
| E3.S3 | **ClarificationCard** component | Vraag + keuze-knoppen bij ambigue input | ✅ | — | 2 |
|
||||
| E3.S4 | **Processing indicator** | Spinner/skeleton bij AI-acties | ✅ | — | 1 |
|
||||
|
||||
**Deliverable:** Multi-intent flows visueel weergegeven
|
||||
|
||||
@@ -736,9 +736,9 @@ interface ActionChainCardProps {
|
||||
```
|
||||
|
||||
*Done criteria:*
|
||||
- [ ] Toont alle acties in chain
|
||||
- [ ] Header toont aantal acties
|
||||
- [ ] AI reasoning collapsible (details/summary)
|
||||
- [x] Toont alle acties in chain
|
||||
- [x] Header toont aantal acties
|
||||
- [x] AI reasoning collapsible (details/summary)
|
||||
|
||||
---
|
||||
|
||||
@@ -777,10 +777,10 @@ const INTENT_LABELS = {
|
||||
```
|
||||
|
||||
*Done criteria:*
|
||||
- [ ] Status icon correct per status
|
||||
- [ ] Confidence badge met juiste kleur
|
||||
- [ ] Confirmation buttons bij `confirming` status
|
||||
- [ ] Retry button bij `failed` status
|
||||
- [x] Status icon correct per status
|
||||
- [x] Confidence badge met juiste kleur
|
||||
- [x] Confirmation buttons bij `confirming` status
|
||||
- [x] Retry button bij `failed` status
|
||||
|
||||
---
|
||||
|
||||
@@ -808,9 +808,9 @@ interface ClarificationCardProps {
|
||||
```
|
||||
|
||||
*Done criteria:*
|
||||
- [ ] Vraag duidelijk zichtbaar
|
||||
- [ ] Knoppen voor elke optie
|
||||
- [ ] Click triggert `onSelect` callback
|
||||
- [x] Vraag duidelijk zichtbaar
|
||||
- [x] Knoppen voor elke optie
|
||||
- [x] Click triggert `onSelect` callback
|
||||
|
||||
---
|
||||
|
||||
@@ -836,9 +836,9 @@ interface ClarificationCardProps {
|
||||
```
|
||||
|
||||
*Done criteria:*
|
||||
- [ ] Indicator zichtbaar tijdens AI call
|
||||
- [ ] Verdwijnt zodra response binnen is
|
||||
- [ ] Geen "frozen" UI gevoel
|
||||
- [x] Indicator zichtbaar tijdens AI call
|
||||
- [x] Verdwijnt zodra response binnen is
|
||||
- [x] Geen "frozen" UI gevoel
|
||||
|
||||
---
|
||||
|
||||
@@ -1398,3 +1398,4 @@ De MVP User Stories uit `mvp-userstories-intent-system.md` zijn als volgt verdee
|
||||
| v1.0 | 30-12-2025 | Colin Lit | Initiële versie op basis van PRD, FO, TO, Architecture docs |
|
||||
| v1.1 | 31-12-2025 | Colin Lit | Dev-instructies per epic toegevoegd, file mappings, done criteria |
|
||||
| v1.2 | 01-01-2026 | Colin Lit | Epic 2 (Intent Orchestrator) compleet - alle 6 stories afgerond |
|
||||
| v1.3 | 01-01-2026 | Colin Lit | Epic 3 (UI Components) compleet - ActionChainCard, ActionItem, ClarificationCard, ProcessingIndicator |
|
||||
|
||||
182
lib/cortex/intent-labels.ts
Normal file
182
lib/cortex/intent-labels.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Intent Labels & Status Styles
|
||||
*
|
||||
* Shared Dutch labels and styling for Cortex V2 UI components.
|
||||
* Used by ActionChainCard, ActionItem, and ClarificationCard.
|
||||
*
|
||||
* Epic: E3 (UI Components)
|
||||
*/
|
||||
|
||||
import type { CortexIntent, IntentActionStatus, NudgePriority, ExtractedEntities } from './types';
|
||||
|
||||
/**
|
||||
* Dutch labels for intent types
|
||||
*/
|
||||
export const INTENT_LABELS: Record<CortexIntent, string> = {
|
||||
dagnotitie: 'Dagnotitie',
|
||||
zoeken: 'Patiënt zoeken',
|
||||
overdracht: 'Overdracht',
|
||||
agenda_query: 'Agenda bekijken',
|
||||
create_appointment: 'Afspraak maken',
|
||||
cancel_appointment: 'Afspraak annuleren',
|
||||
reschedule_appointment: 'Afspraak verzetten',
|
||||
unknown: 'Onbekend',
|
||||
};
|
||||
|
||||
/**
|
||||
* Status styling for IntentAction items
|
||||
*/
|
||||
export const STATUS_STYLES: Record<
|
||||
IntentActionStatus,
|
||||
{ bg: string; border: string; text: string; icon: string }
|
||||
> = {
|
||||
pending: {
|
||||
bg: 'bg-slate-50',
|
||||
border: 'border-slate-200',
|
||||
text: 'text-slate-700',
|
||||
icon: 'text-slate-300',
|
||||
},
|
||||
confirming: {
|
||||
bg: 'bg-amber-50',
|
||||
border: 'border-amber-200',
|
||||
text: 'text-amber-700',
|
||||
icon: 'text-amber-500',
|
||||
},
|
||||
executing: {
|
||||
bg: 'bg-blue-50',
|
||||
border: 'border-blue-200',
|
||||
text: 'text-blue-700',
|
||||
icon: 'text-blue-500',
|
||||
},
|
||||
success: {
|
||||
bg: 'bg-green-50',
|
||||
border: 'border-green-200',
|
||||
text: 'text-green-700',
|
||||
icon: 'text-green-500',
|
||||
},
|
||||
failed: {
|
||||
bg: 'bg-red-50',
|
||||
border: 'border-red-200',
|
||||
text: 'text-red-700',
|
||||
icon: 'text-red-500',
|
||||
},
|
||||
skipped: {
|
||||
bg: 'bg-slate-50',
|
||||
border: 'border-slate-200',
|
||||
text: 'text-slate-400',
|
||||
icon: 'text-slate-400',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get confidence badge styling based on confidence score
|
||||
* >= 0.9: green (high confidence)
|
||||
* >= 0.7: amber (medium confidence)
|
||||
* < 0.7: red (low confidence)
|
||||
*/
|
||||
export function getConfidenceStyle(confidence: number): {
|
||||
bg: string;
|
||||
text: string;
|
||||
label: string;
|
||||
} {
|
||||
if (confidence >= 0.9) {
|
||||
return {
|
||||
bg: 'bg-green-50',
|
||||
text: 'text-green-700',
|
||||
label: 'Hoog',
|
||||
};
|
||||
}
|
||||
if (confidence >= 0.7) {
|
||||
return {
|
||||
bg: 'bg-amber-50',
|
||||
text: 'text-amber-700',
|
||||
label: 'Gemiddeld',
|
||||
};
|
||||
}
|
||||
return {
|
||||
bg: 'bg-red-50',
|
||||
text: 'text-red-700',
|
||||
label: 'Laag',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Priority styling for NudgeSuggestion toasts
|
||||
*/
|
||||
export const PRIORITY_STYLES: Record<
|
||||
NudgePriority,
|
||||
{ bg: string; border: string; text: string }
|
||||
> = {
|
||||
low: {
|
||||
bg: 'bg-blue-50',
|
||||
border: 'border-blue-200',
|
||||
text: 'text-blue-700',
|
||||
},
|
||||
medium: {
|
||||
bg: 'bg-amber-50',
|
||||
border: 'border-amber-200',
|
||||
text: 'text-amber-700',
|
||||
},
|
||||
high: {
|
||||
bg: 'bg-red-50',
|
||||
border: 'border-red-200',
|
||||
text: 'text-red-700',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Format entity summary for display in ActionItem
|
||||
*/
|
||||
export function formatEntitySummary(
|
||||
intent: CortexIntent,
|
||||
entities: ExtractedEntities
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Patient name
|
||||
if (entities.patientName) {
|
||||
parts.push(`Patiënt: ${entities.patientName}`);
|
||||
}
|
||||
|
||||
// Content/query
|
||||
if (entities.content && typeof entities.content === 'string') {
|
||||
const truncated =
|
||||
entities.content.length > 50
|
||||
? `${entities.content.slice(0, 50)}...`
|
||||
: entities.content;
|
||||
parts.push(truncated);
|
||||
}
|
||||
|
||||
if (entities.query && typeof entities.query === 'string') {
|
||||
parts.push(`Zoekterm: "${entities.query}"`);
|
||||
}
|
||||
|
||||
// Date/time
|
||||
if (entities.datetime) {
|
||||
const dt = entities.datetime;
|
||||
// datetime.date is a Date object, format it
|
||||
const dateStr = dt.date ? dt.date.toLocaleDateString('nl-NL') : null;
|
||||
if (dateStr && dt.time) {
|
||||
parts.push(`${dateStr} om ${dt.time}`);
|
||||
} else if (dateStr) {
|
||||
parts.push(dateStr);
|
||||
} else if (dt.time) {
|
||||
parts.push(`om ${dt.time}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Category for dagnotitie
|
||||
if (intent === 'dagnotitie' && entities.category) {
|
||||
const categoryLabels: Record<string, string> = {
|
||||
medicatie: 'Medicatie',
|
||||
adl: 'ADL',
|
||||
gedrag: 'Gedrag',
|
||||
incident: 'Incident',
|
||||
observatie: 'Observatie',
|
||||
};
|
||||
const label = categoryLabels[entities.category as string] || entities.category;
|
||||
parts.push(`Categorie: ${label}`);
|
||||
}
|
||||
|
||||
return parts.join(' • ') || 'Geen details';
|
||||
}
|
||||
Reference in New Issue
Block a user