Epic 3 Story 4 compleet: AI action objects worden geparsed en gevalideerd.
E3.S4 - Intent Detection in Response (5 SP)
- Action parser met JSON extraction uit markdown code blocks
- Zod validatie voor action schema (intent, entities, confidence, artifact)
- Confidence-based artifact opening (≥0.7 threshold)
- Visual feedback met action badges in chat messages
- Console logging voor debugging
Nieuwe Components:
- lib/swift/action-parser.ts (149 regels)
- parseActionFromResponse(): Extract & validate JSON actions
- extractJsonBlock(): Regex matching voor ```json blocks
- removeJsonBlocks(): Clean text zonder JSON
- shouldOpenArtifact(): Confidence check (≥0.7)
- getConfidenceLabel(): "Zeer zeker", "Redelijk zeker", etc.
- validateArtifactType(): Intent/artifact type matching
Components Updated:
- components/swift/chat/chat-panel.tsx
- Action parsing in onDone callback
- setPendingAction voor artifact opening (E3.S6)
- Console logging: "[ChatPanel] Action detected"
- components/swift/chat/chat-message.tsx
- Action badge met Sparkles icon
- Intent label (Dagnotitie, Patiënt zoeken, Overdracht)
- CheckCircle icon voor high confidence (≥0.7)
- Confidence label display
- stores/swift-store.ts
- updateLastMessage(): Optional action parameter
- Type-safe action assignment (null/undefined handling)
Features:
- JSON extraction via regex: /```json\s*\n([\s\S]*?)\n```/
- Zod schema: type, intent, entities, confidence, artifact
- Confidence thresholds: >0.9, 0.7-0.9, 0.5-0.7, <0.5
- Visual feedback: Sparkles + CheckCircle icons
- Cleaned text content (JSON removed from display)
- Action stored in message.action en pendingAction state
Action Schema:
```typescript
{
type: "action",
intent: "dagnotitie" | "zoeken" | "overdracht" | "unknown",
entities: {
patientName?: string,
patientId?: string,
category?: "medicatie" | "adl" | "gedrag" | "incident" | "observatie",
content?: string,
query?: string
},
confidence: number, // 0-1
artifact?: {
type: BlockType,
prefill: Record<string, any>
}
}
```
Build Status:
- ✅ pnpm build succesvol (geen type errors)
- Alleen bekende warnings (Supabase realtime, useCallback)
Voortgang: 46 SP / 85 SP (54%) - E3.S4 compleet
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
216 lines
7.6 KiB
TypeScript
216 lines
7.6 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* Chat Panel (v3.0)
|
|
*
|
|
* Chat interface met scrollable message list, auto-scroll, en scroll-lock detection.
|
|
*
|
|
* Epic: E2 (Chat Panel & Messages)
|
|
* Story: E2.S3 (ChatPanel component - scrolling)
|
|
*/
|
|
|
|
import { useRef, useEffect, useState, useCallback } from 'react';
|
|
import { ArrowDown } from 'lucide-react';
|
|
import { ChatMessage } from './chat-message';
|
|
import { ChatInput, ChatInputHandle } from './chat-input';
|
|
import { useSwiftStore } from '@/stores/swift-store';
|
|
import { sendChatMessage } from '@/lib/swift/chat-api';
|
|
import { parseActionFromResponse, shouldOpenArtifact } from '@/lib/swift/action-parser';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
export function ChatPanel() {
|
|
const chatMessages = useSwiftStore((s) => s.chatMessages);
|
|
const addChatMessage = useSwiftStore((s) => s.addChatMessage);
|
|
const updateLastMessage = useSwiftStore((s) => s.updateLastMessage);
|
|
const setStreaming = useSwiftStore((s) => s.setStreaming);
|
|
const isStreaming = useSwiftStore((s) => s.isStreaming);
|
|
const setPendingAction = useSwiftStore((s) => s.setPendingAction);
|
|
const activePatient = useSwiftStore((s) => s.activePatient);
|
|
const shift = useSwiftStore((s) => s.shift);
|
|
|
|
// Refs for scrolling
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Ref for chat input (for keyboard shortcuts)
|
|
const chatInputRef = useRef<ChatInputHandle>(null);
|
|
|
|
// Scroll-lock state
|
|
const [isScrolledUp, setIsScrolledUp] = useState(false);
|
|
const [showScrollButton, setShowScrollButton] = useState(false);
|
|
|
|
const hasMessages = chatMessages.length > 0;
|
|
|
|
// Scroll to bottom function
|
|
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior });
|
|
setIsScrolledUp(false);
|
|
setShowScrollButton(false);
|
|
}, []);
|
|
|
|
// Detect scroll position (scroll-lock detection)
|
|
const handleScroll = useCallback(() => {
|
|
if (!scrollContainerRef.current) return;
|
|
|
|
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
|
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100; // 100px threshold
|
|
|
|
setIsScrolledUp(!isNearBottom);
|
|
|
|
// Show scroll button only if scrolled up AND there are messages
|
|
setShowScrollButton(!isNearBottom && hasMessages);
|
|
}, [hasMessages]);
|
|
|
|
// Auto-scroll to latest message when new message arrives (unless user scrolled up)
|
|
useEffect(() => {
|
|
if (!isScrolledUp && hasMessages) {
|
|
scrollToBottom('smooth');
|
|
}
|
|
}, [chatMessages.length, isScrolledUp, hasMessages, scrollToBottom]);
|
|
|
|
// Initial scroll to bottom on mount
|
|
useEffect(() => {
|
|
scrollToBottom('auto');
|
|
}, [scrollToBottom]);
|
|
|
|
// Global keyboard shortcuts
|
|
useEffect(() => {
|
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
|
// ⌘K or Ctrl+K to focus chat input
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
e.preventDefault();
|
|
chatInputRef.current?.focus();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleGlobalKeyDown);
|
|
return () => window.removeEventListener('keydown', handleGlobalKeyDown);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="h-full flex flex-col bg-white">
|
|
{/* Chat messages area - scrollable */}
|
|
<div
|
|
ref={scrollContainerRef}
|
|
onScroll={handleScroll}
|
|
className="flex-1 overflow-y-auto p-6 relative"
|
|
>
|
|
{hasMessages ? (
|
|
<div className="flex flex-col space-y-3">
|
|
{chatMessages.map((message) => (
|
|
<ChatMessage key={message.id} message={message} showTimestamp />
|
|
))}
|
|
{/* Invisible element to scroll to */}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="max-w-md text-center text-slate-500">
|
|
<div className="text-4xl mb-4">💬</div>
|
|
<h3 className="text-lg font-medium text-slate-700 mb-2">
|
|
Welkom bij Swift Medical Scribe
|
|
</h3>
|
|
<p className="text-sm mb-4">
|
|
Typ of spreek wat je wilt doen...
|
|
</p>
|
|
<div className="text-left text-sm space-y-1 bg-slate-50 rounded-lg p-4">
|
|
<p className="font-medium text-slate-700 mb-2">Voorbeelden:</p>
|
|
<p>• “Notitie voor Jan: medicatie gegeven”</p>
|
|
<p>• “Zoek Marie van den Berg”</p>
|
|
<p>• “Maak overdracht voor deze dienst”</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Scroll to bottom button */}
|
|
{showScrollButton && (
|
|
<button
|
|
onClick={() => scrollToBottom('smooth')}
|
|
className={cn(
|
|
'absolute bottom-4 right-4 z-10',
|
|
'bg-white border border-slate-300 rounded-full p-2',
|
|
'shadow-lg hover:shadow-xl',
|
|
'transition-all duration-200',
|
|
'hover:bg-slate-50 active:scale-95',
|
|
'flex items-center gap-2 text-sm text-slate-700 font-medium px-3 py-2'
|
|
)}
|
|
aria-label="Scroll naar laatste bericht"
|
|
>
|
|
<ArrowDown className="w-4 h-4" />
|
|
<span>Scroll naar beneden</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Chat input */}
|
|
<ChatInput
|
|
ref={chatInputRef}
|
|
disabled={isStreaming}
|
|
onSend={async (message) => {
|
|
// E3.S1: Test streaming API with mock response
|
|
setStreaming(true);
|
|
|
|
// Add empty assistant message that will be filled by streaming
|
|
addChatMessage({
|
|
type: 'assistant',
|
|
content: '',
|
|
});
|
|
|
|
let accumulatedContent = '';
|
|
|
|
await sendChatMessage(
|
|
message,
|
|
chatMessages,
|
|
{
|
|
activePatient: activePatient ? {
|
|
id: activePatient.id,
|
|
first_name: activePatient.name_given?.[0] || '',
|
|
last_name: activePatient.name_family || '',
|
|
} : null,
|
|
shift,
|
|
},
|
|
(chunk) => {
|
|
// On each chunk, append to accumulated content and update last message
|
|
accumulatedContent += chunk;
|
|
updateLastMessage(accumulatedContent);
|
|
},
|
|
() => {
|
|
// On done - parse action from complete response
|
|
setStreaming(false);
|
|
|
|
// E3.S4: Parse action object from AI response
|
|
const parsed = parseActionFromResponse(accumulatedContent);
|
|
|
|
if (parsed.action) {
|
|
console.log('[ChatPanel] Action detected:', parsed.action);
|
|
|
|
// Update last message with cleaned text content and action
|
|
updateLastMessage(parsed.textContent, parsed.action);
|
|
|
|
// Store action in pendingAction for artifact opening (E3.S6)
|
|
if (shouldOpenArtifact(parsed.action.confidence)) {
|
|
setPendingAction(parsed.action);
|
|
console.log('[ChatPanel] Pending action set (confidence:', parsed.action.confidence, ')');
|
|
} else {
|
|
console.log('[ChatPanel] Action confidence too low:', parsed.action.confidence);
|
|
}
|
|
} else {
|
|
console.log('[ChatPanel] No action detected in response');
|
|
}
|
|
},
|
|
(error) => {
|
|
// On error
|
|
setStreaming(false);
|
|
addChatMessage({
|
|
type: 'error',
|
|
content: `Er ging iets mis: ${error}`,
|
|
});
|
|
}
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|