'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(null); const messagesEndRef = useRef(null); // Ref for chat input (for keyboard shortcuts) const chatInputRef = useRef(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 (
{/* Chat messages area - scrollable */}
{hasMessages ? (
{chatMessages.map((message) => ( ))} {/* Invisible element to scroll to */}
) : (
💬

Welkom bij Swift Medical Scribe

Typ of spreek wat je wilt doen...

Voorbeelden:

• “Notitie voor Jan: medicatie gegeven”

• “Zoek Marie van den Berg”

• “Maak overdracht voor deze dienst”

)} {/* Scroll to bottom button */} {showScrollButton && ( )}
{/* Chat input */} { // 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}`, }); } ); }} />
); }