refactor: rename swift → cortex in code and documentation

Complete rename of "swift" terminology to "cortex" across the codebase:

Code Changes:
- Rename directories: lib/swift → lib/cortex, components/swift → components/cortex
- Rename API routes: /api/swift/* → /api/cortex/*
- Rename page routes: /epd/swift → /epd/cortex
- Rename store: swift-store.ts → cortex-store.ts

Type Renames:
- SwiftIntent → CortexIntent
- SwiftStore → CortexStore
- useSwiftStore → useCortexStore
- SwiftContext → CortexContext
- useSwiftVoice → useCortexVoice

Documentation Updates (docs/intent/):
- Safety Net → Nudge (Layer 3 rename)
- SafetySuggestion → NudgeSuggestion
- evaluateSafetyNet → evaluateNudge
- SWIFT_* feature flags → CORTEX_*
- All path references updated to match new structure

Files affected: 47 files, ~700 lines changed

🤖 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
2025-12-30 09:18:06 +01:00
parent c8aaba657e
commit 2170b23348
62 changed files with 364 additions and 355 deletions

View File

@@ -0,0 +1,146 @@
'use client';
/**
* Canvas Area
*
* Central area where blocks appear. Shows empty state when no block is active.
*/
import { AnimatePresence, motion } from 'framer-motion';
import { useCortexStore } from '@/stores/cortex-store';
import type { BlockType, BlockPrefillData } from '@/stores/cortex-store';
import { DagnotatieBlock } from '../blocks/dagnotitie-block';
import { ZoekenBlock } from '../blocks/zoeken-block';
import { OverdrachtBlock } from '../blocks/overdracht-block';
import { PatientContextCard } from '../blocks/patient-context-card';
import { FallbackPicker } from '../blocks/fallback-picker';
export function CanvasArea() {
const { activeBlock, prefillData, activePatient } = useCortexStore();
function renderBlock(blockType: BlockType, prefill: BlockPrefillData) {
switch (blockType) {
case 'dagnotitie':
return <DagnotatieBlock prefill={prefill} />;
case 'zoeken':
return <ZoekenBlock prefill={prefill} />;
case 'overdracht':
return <OverdrachtBlock prefill={prefill} />;
case 'fallback':
return <FallbackPicker originalInput={prefill.content} />;
default:
return null;
}
}
// Block animations volgens UX specificatie (sectie 11.1):
// Openen: Slide up + fade in (200ms), Scale: 0.95 → 1.0
// Sluiten: Slide down + fade out (200ms), Scale: 1.0 → 0.95
const blockAnimations = {
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 [number, number, number, number],
},
},
exit: {
opacity: 0,
y: 20, // Slide down (niet omhoog)
scale: 0.95,
transition: {
duration: 0.2,
ease: [0.4, 0, 0.2, 1] as [number, number, number, number],
},
},
};
return (
<main className="flex-1 flex items-center justify-center p-4 overflow-auto">
<AnimatePresence mode="wait" initial={false}>
{activeBlock ? (
<motion.div
key={activeBlock}
initial={blockAnimations.initial}
animate={blockAnimations.animate}
exit={blockAnimations.exit}
>
{renderBlock(activeBlock, prefillData)}
</motion.div>
) : activePatient ? (
<motion.div
key="patient-context"
initial={blockAnimations.initial}
animate={blockAnimations.animate}
exit={blockAnimations.exit}
>
<PatientContextCard />
</motion.div>
) : (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<EmptyState />
</motion.div>
)}
</AnimatePresence>
</main>
);
}
function EmptyState() {
return (
<div className="text-center max-w-md">
<div className="mb-6">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white border border-slate-200 shadow-sm flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-slate-400"
>
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>
</div>
<h2 className="text-xl font-medium text-slate-700 mb-2">Wat wil je doen?</h2>
<p className="text-sm text-slate-500">
Typ of spreek je intentie
</p>
</div>
<div className="space-y-2 text-left">
<ExampleCommand icon="📝" text="notitie jan medicatie gegeven" />
<ExampleCommand icon="🔍" text="zoek marie" />
<ExampleCommand icon="📋" text="overdracht" />
</div>
<p className="mt-6 text-xs text-slate-500">
<kbd className="px-1.5 py-0.5 rounded bg-white border border-slate-200 text-slate-600 shadow-sm">K</kbd> om te focussen
</p>
</div>
);
}
function ExampleCommand({ icon, text }: { icon: string; text: string }) {
return (
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-white border border-slate-200 shadow-sm text-sm">
<span>{icon}</span>
<span className="text-slate-600">&quot;{text}&quot;</span>
</div>
);
}

View File

@@ -0,0 +1,123 @@
'use client';
/**
* Command Center (v3.0)
*
* Main container for the Cortex interface.
* Split-screen layout: Chat Panel (40%) | Artifact Area (60%)
*
* Layout specs:
* - Context Bar: 48px (h-12) - UNCHANGED
* - Split container: flex-1 (fills remaining space)
* - Chat Panel: 40% width (desktop), 100% (mobile)
* - Artifact Area: 60% width (desktop), 100% (mobile)
*
* Epic: E1 (Foundation)
* Stories: E1.S2 (Split-screen layout), E1.S3 (Placeholders), E1.S4 (Responsive)
*/
import { useEffect, useCallback, useRef } from 'react';
import { useCortexStore } from '@/stores/cortex-store';
import { ContextBar } from './context-bar';
import { OfflineBanner } from './offline-banner';
import { ChatPanel } from '../chat/chat-panel';
import { ArtifactArea } from '../artifacts/artifact-area';
import { getArtifactTitle } from '../artifacts/artifact-container';
import { routeIntentToArtifact } from '@/lib/cortex/action-parser';
export function CommandCenter() {
const { closeAllArtifacts, openArtifacts, openArtifact, pendingAction, setPendingAction } = useCortexStore();
const inputRef = useRef<HTMLInputElement>(null);
// Global keyboard shortcuts
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
// Escape: close all artifacts
if (e.key === 'Escape' && openArtifacts.length > 0) {
e.preventDefault();
closeAllArtifacts();
}
// Cmd/Ctrl + K: focus input (chat input in v3.0)
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
inputRef.current?.focus();
}
},
[openArtifacts, closeAllArtifacts]
);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
// E3.S6 + E4.S2: Handle pending actions from chat (artifact opening)
useEffect(() => {
if (!pendingAction) return;
console.log('[CommandCenter] Processing pending action:', pendingAction);
// Check if action has artifact data
if (pendingAction.artifact) {
const { type, prefill } = pendingAction.artifact;
// Generate title for the artifact
const title = getArtifactTitle(type, prefill);
console.log('[CommandCenter] Opening artifact:', type, title);
// Open the artifact (E4.S2 - new artifact system)
openArtifact({
type,
prefill,
title,
});
setPendingAction(null);
return;
}
const routedArtifact = routeIntentToArtifact(
pendingAction.intent,
pendingAction.entities,
pendingAction.confidence
);
if (routedArtifact) {
console.log('[CommandCenter] Routing action to artifact:', routedArtifact.type);
openArtifact({
type: routedArtifact.type,
prefill: routedArtifact.prefill,
title: routedArtifact.title,
});
} else {
console.log('[CommandCenter] Action has no artifact, skipping');
}
setPendingAction(null);
}, [pendingAction, openArtifact, setPendingAction]);
return (
<div className="flex flex-col h-screen overflow-hidden">
{/* Offline Banner */}
<OfflineBanner />
{/* Context Bar - 48px (unchanged) */}
<ContextBar />
{/* Split-screen container - flex-1 */}
<div className="flex-1 flex overflow-hidden">
{/* Chat Panel - 40% (desktop), 100% (mobile) */}
<div className="w-full lg:w-[40%] border-r border-slate-200 flex flex-col">
<ChatPanel />
</div>
{/* Artifact Area - 60% (desktop), hidden on mobile */}
<div className="hidden lg:flex lg:w-[60%] flex-col">
<ArtifactArea />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,315 @@
'use client';
/**
* Command Input
*
* Bottom input bar for text and voice commands.
* Height: 64px (h-16)
*
* Features:
* - Text input with dynamic placeholder
* - Focus state with ring
* - Send button (appears when input has value)
* - Voice input with Deepgram streaming
* - ⌘K shortcut hint
*/
import { forwardRef, useState, useEffect, useRef } from 'react';
import { useCortexStore } from '@/stores/cortex-store';
import { useCortexVoice } from '@/lib/cortex/use-cortex-voice';
import type { BlockType } from '@/lib/cortex/types';
import { Mic, MicOff, Send, Loader2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { safeFetch, getErrorInfo } from '@/lib/cortex/error-handler';
import { routeIntentToArtifact } from '@/lib/cortex/action-parser';
export const CommandInput = forwardRef<HTMLInputElement>(function CommandInput(_, ref) {
const {
inputValue,
setInputValue,
clearInput,
activePatient,
activeBlock,
isVoiceActive,
openBlock,
openArtifact,
addRecentAction,
} = useCortexStore();
const { toast } = useToast();
const {
isRecording,
isConnecting,
isConnected,
error: voiceError,
startRecording,
stopRecording,
analyserNode,
isBrowserSupported,
} = useCortexVoice();
const [isProcessing, setIsProcessing] = useState(false);
const waveformRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number | null>(null);
const hasValue = inputValue.trim().length > 0;
// Waveform visualization
useEffect(() => {
if (!analyserNode || !waveformRef.current || !isRecording) {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = null;
}
return;
}
const canvas = waveformRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const bufferLength = analyserNode.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const draw = () => {
if (!isRecording) return;
animationRef.current = requestAnimationFrame(draw);
analyserNode.getByteFrequencyData(dataArray);
ctx.fillStyle = 'rgb(248, 250, 252)'; // slate-50
ctx.fillRect(0, 0, canvas.width, canvas.height);
const barWidth = (canvas.width / bufferLength) * 2.5;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const barHeight = (dataArray[i] / 255) * canvas.height;
// Gradient from blue to red based on amplitude
const hue = 220 - (dataArray[i] / 255) * 40;
ctx.fillStyle = `hsl(${hue}, 70%, 60%)`;
ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
x += barWidth + 1;
}
};
draw();
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [analyserNode, isRecording]);
// Dynamic placeholder based on context
const getPlaceholder = () => {
if (isRecording) return 'Luisteren...';
if (isConnecting) return 'Verbinden met spraakherkenning...';
if (activePatient) {
return `Actie voor ${activePatient.name_given[0]}... (bijv. "notitie medicatie")`;
}
return 'Typ of spreek je intentie... (bijv. "notitie jan medicatie")';
};
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!hasValue || isProcessing) return;
// Stop recording if active
if (isRecording) {
stopRecording();
}
const inputText = inputValue.trim();
setIsProcessing(true);
try {
// Call intent classification API
const response = await safeFetch(
'/api/intent/classify',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: inputText }),
},
{ operation: 'Intent classificeren' }
);
const result = await response.json();
const { intent, confidence, entities } = result;
// Route intent to artifact using Epic 5.S1 routing logic
const artifactConfig = routeIntentToArtifact(intent, entities, confidence);
if (artifactConfig) {
// Open artifact with routing configuration
openArtifact({
type: artifactConfig.type,
title: artifactConfig.title,
prefill: artifactConfig.prefill,
});
// Add to recent actions
addRecentAction({
intent,
label: inputText.slice(0, 50), // Truncate for display
patientName: entities.patientName,
});
clearInput();
} else {
// Low confidence or missing required entities - show FallbackPicker
openBlock('fallback', { content: inputText });
clearInput();
}
} catch (error) {
console.error('Error processing intent:', error);
const statusCode = (error as any)?.statusCode;
const errorInfo = getErrorInfo(error, {
operation: 'Intent classificeren',
statusCode,
});
// Show error toast
toast({
variant: 'destructive',
title: errorInfo.title,
description: errorInfo.description,
});
// On error, show FallbackPicker so user can choose
// This ensures the user's input is not lost
openBlock('fallback', { content: inputText });
clearInput();
} finally {
setIsProcessing(false);
}
};
const handleVoiceToggle = () => {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
};
// Keyboard shortcut: Cmd/Ctrl+Enter to submit
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Cmd/Ctrl+Enter: submit command
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSubmit]);
const isDisabled = isProcessing || activeBlock !== null;
return (
<footer className="h-16 border-t border-slate-200 flex items-center px-4 shrink-0 bg-white">
<form onSubmit={handleSubmit} className="flex-1 flex items-center gap-2">
{/* Input wrapper with optional waveform */}
<div className="relative flex-1">
{/* Waveform canvas (shown when recording) */}
{isRecording && (
<canvas
ref={waveformRef}
width={200}
height={40}
className="absolute left-2 top-1/2 -translate-y-1/2 rounded opacity-60"
/>
)}
<input
ref={ref}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder={getPlaceholder()}
disabled={isDisabled}
className={`w-full bg-white border rounded-xl py-3 text-slate-900 placeholder:text-slate-400
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200
${isRecording ? 'pl-[220px] pr-12 border-red-500/50' : 'pl-4 pr-12 border-slate-300'}`}
autoFocus
/>
{/* Status indicators */}
{!hasValue && !isRecording && !isConnecting && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-600 pointer-events-none hidden sm:block">
K
</span>
)}
{isConnecting && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-amber-500 flex items-center gap-1">
<Loader2 size={12} className="animate-spin" />
Verbinden
</span>
)}
{voiceError && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-red-400 max-w-[150px] truncate">
{voiceError}
</span>
)}
</div>
{/* Send button - appears when has value */}
{hasValue && (
<button
type="submit"
disabled={isProcessing}
className="p-3 rounded-xl bg-blue-600 hover:bg-blue-500 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Verstuur (Enter)"
>
{isProcessing ? (
<Loader2 size={20} className="animate-spin" />
) : (
<Send size={20} />
)}
</button>
)}
{/* Voice button */}
{isBrowserSupported ? (
<button
type="button"
onClick={handleVoiceToggle}
disabled={isDisabled || isConnecting}
className={`p-3 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed ${
isRecording
? 'bg-red-600 hover:bg-red-500 text-white ring-4 ring-red-600/30'
: 'bg-slate-100 hover:bg-slate-200 text-slate-600'
}`}
title={isRecording ? 'Stop opname' : 'Start voice input'}
>
{isConnecting ? (
<Loader2 size={20} className="animate-spin" />
) : isRecording ? (
<MicOff size={20} />
) : (
<Mic size={20} />
)}
</button>
) : (
<button
type="button"
disabled
className="p-3 rounded-xl bg-slate-100 text-slate-400 cursor-not-allowed"
title="Spraakherkenning niet ondersteund in deze browser"
>
<MicOff size={20} />
</button>
)}
</form>
</footer>
);
});

View File

@@ -0,0 +1,85 @@
'use client';
/**
* Context Bar
*
* Top bar showing current context: shift, selected patient, user info.
* Height: 48px (h-12)
*/
import { useCortexStore, type ShiftType } from '@/stores/cortex-store';
import { Sun, Moon, Sunrise, Sunset, X, User, ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import { useOffline } from './offline-banner';
const SHIFT_CONFIG: Record<ShiftType, { icon: typeof Sun; label: string; color: string }> = {
nacht: { icon: Moon, label: 'Nachtdienst', color: 'text-indigo-600' },
ochtend: { icon: Sunrise, label: 'Ochtenddienst', color: 'text-amber-600' },
middag: { icon: Sun, label: 'Middagdienst', color: 'text-yellow-600' },
avond: { icon: Sunset, label: 'Avonddienst', color: 'text-orange-600' },
};
export function ContextBar() {
const { shift, activePatient, setActivePatient } = useCortexStore();
const isOffline = useOffline();
const shiftConfig = SHIFT_CONFIG[shift];
const ShiftIcon = shiftConfig.icon;
return (
<header
className="h-12 border-b border-slate-200 flex items-center px-4 justify-between shrink-0 bg-white"
style={isOffline ? { marginTop: '40px' } : undefined}
>
{/* Left: Logo + Shift */}
<div className="flex items-center gap-4">
<Link
href="/epd/clients"
className="flex items-center gap-2 text-slate-600 hover:text-slate-900 transition-colors"
title="Terug naar EPD"
>
<ArrowLeft size={16} />
<span className="text-sm font-semibold tracking-tight">Terug naar EPD</span>
</Link>
<div className="h-4 w-px bg-slate-200" />
<div className={`flex items-center gap-1.5 ${shiftConfig.color}`}>
<ShiftIcon size={14} />
<span className="text-xs font-medium">{shiftConfig.label}</span>
</div>
</div>
{/* Center: Active Patient */}
<div className="flex items-center">
{activePatient ? (
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-slate-100 border border-slate-300">
<div className="w-5 h-5 rounded-full bg-blue-600 flex items-center justify-center text-[10px] font-medium text-white">
{activePatient.name_given[0]?.[0]}
{activePatient.name_family[0]}
</div>
<span className="text-sm text-slate-900">
{activePatient.name_given.join(' ')} {activePatient.name_family}
</span>
<button
onClick={() => setActivePatient(null)}
className="p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-700 transition-colors"
title="Patiënt deselecteren"
>
<X size={14} />
</button>
</div>
) : (
<span className="text-sm text-slate-500">Geen patiënt geselecteerd</span>
)}
</div>
{/* Right: User */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-slate-600">
<User size={16} />
<span className="text-xs hidden sm:block">Verpleegkundige</span>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,9 @@
/**
* Command Center Barrel Export
*/
export { CommandCenter } from './command-center';
export { CommandInput } from './command-input';
export { ContextBar } from './context-bar';
export { CanvasArea } from './canvas-area';
export { RecentStrip } from './recent-strip';

View File

@@ -0,0 +1,71 @@
'use client';
/**
* Offline Banner
*
* Toont een banner wanneer de gebruiker offline is.
* E5.S2: Offline detection en melding.
*/
import { useState, useEffect } from 'react';
import { WifiOff } from 'lucide-react';
import { cn } from '@/lib/utils';
export function OfflineBanner() {
const [isOffline, setIsOffline] = useState(false);
useEffect(() => {
// Initial check
setIsOffline(!navigator.onLine);
// Listen for online/offline events
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
if (!isOffline) return null;
return (
<div
className={cn(
'fixed top-0 left-0 right-0 z-[100] bg-amber-500 text-white px-4 py-2',
'flex items-center justify-center gap-2 text-sm font-medium',
'shadow-md'
)}
style={{ height: '40px' }}
>
<WifiOff className="h-4 w-4" />
<span>Geen internetverbinding</span>
</div>
);
}
/**
* Hook om te checken of de gebruiker offline is
*/
export function useOffline() {
const [isOffline, setIsOffline] = useState(false);
useEffect(() => {
setIsOffline(!navigator.onLine);
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOffline;
}

View File

@@ -0,0 +1,115 @@
'use client';
/**
* Recent Strip
*
* Shows last 5 actions as clickable chips for quick repeat.
* Height: 48px (h-12)
*/
import { useCortexStore, type CortexIntent } from '@/stores/cortex-store';
import { FileText, Search, ArrowRightLeft, HelpCircle, Clock, Calendar, Plus, X } from 'lucide-react';
const INTENT_CONFIG: Record<CortexIntent, { icon: typeof FileText; color: string; label: string }> = {
dagnotitie: { icon: FileText, color: 'text-blue-600 bg-blue-50 border border-blue-200', label: 'Notitie' },
zoeken: { icon: Search, color: 'text-emerald-600 bg-emerald-50 border border-emerald-200', label: 'Zoeken' },
overdracht: { icon: ArrowRightLeft, color: 'text-purple-600 bg-purple-50 border border-purple-200', label: 'Overdracht' },
agenda_query: { icon: Calendar, color: 'text-teal-600 bg-teal-50 border border-teal-200', label: 'Agenda' },
create_appointment: { icon: Plus, color: 'text-green-600 bg-green-50 border border-green-200', label: 'Afspraak' },
cancel_appointment: { icon: X, color: 'text-red-600 bg-red-50 border border-red-200', label: 'Annuleren' },
reschedule_appointment: { icon: Clock, color: 'text-amber-600 bg-amber-50 border border-amber-200', label: 'Verzetten' },
unknown: { icon: HelpCircle, color: 'text-slate-600 bg-slate-50 border border-slate-200', label: 'Actie' },
};
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'zojuist';
if (diffMins < 60) return `${diffMins}m`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}u`;
return `${Math.floor(diffHours / 24)}d`;
}
export function RecentStrip() {
const { recentActions, setInputValue, openBlock } = useCortexStore();
const handleActionClick = (action: typeof recentActions[0]) => {
// Set the input to repeat the action
setInputValue(action.label);
// If it's a known intent, open the block directly
if (action.intent !== 'unknown') {
openBlock(action.intent, {
patientName: action.patientName,
});
}
};
return (
<div className="h-12 border-t border-slate-200 flex items-center px-4 gap-3 shrink-0 bg-white">
{/* Label */}
<div className="flex items-center gap-1.5 text-slate-500 shrink-0">
<Clock size={12} />
<span className="text-xs font-medium">Recent</span>
</div>
{/* Divider */}
<div className="h-4 w-px bg-slate-200" />
{/* Actions */}
{recentActions.length === 0 ? (
<span className="text-xs text-slate-400 italic">Nog geen acties</span>
) : (
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
{recentActions.map((action) => {
const config = INTENT_CONFIG[action.intent];
const Icon = config.icon;
return (
<button
key={action.id}
onClick={() => handleActionClick(action)}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium
whitespace-nowrap transition-all duration-200
hover:scale-105 active:scale-95
${config.color} hover:brightness-110`}
title={`Herhaal: ${action.label}${action.patientName ? ` (${action.patientName})` : ''}`}
>
<Icon size={12} />
<span className="max-w-[120px] truncate">{action.label}</span>
{action.patientName && (
<span className="text-[10px] opacity-60"> {action.patientName}</span>
)}
<span className="text-[10px] opacity-40 ml-1">
{formatRelativeTime(action.timestamp)}
</span>
</button>
);
})}
</div>
)}
{/* Quick actions hint (when empty) */}
{recentActions.length === 0 && (
<div className="ml-auto flex items-center gap-2 text-xs text-slate-500">
<span>Probeer:</span>
<button
onClick={() => setInputValue('notitie ')}
className="px-2 py-0.5 rounded bg-slate-100 hover:bg-slate-200 text-slate-600 transition-colors"
>
notitie
</button>
<button
onClick={() => setInputValue('zoek ')}
className="px-2 py-0.5 rounded bg-slate-100 hover:bg-slate-200 text-slate-600 transition-colors"
>
zoek
</button>
</div>
)}
</div>
);
}