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:
colinislit
2026-01-01 12:02:15 +01:00
parent b36df63d03
commit 42d64761ec
7 changed files with 980 additions and 22 deletions

View 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">
&ldquo;{chain.originalInput}&rdquo;
</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>
);
}

View 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>
);
}

View File

@@ -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>

View 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">&ldquo;{originalInput}&rdquo;</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>
);
}

View 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)}
/>
);
}