import { useState, useRef, useEffect, useCallback, useMemo, type KeyboardEvent } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { MessageCircle, X, Send, Loader2 } from 'lucide-react' import { sendChatMessage, isGeminiAvailable, parseItemIds, stripItemsSuffix, GEMINI_DISPLAY_NAME, type ChatMessage, } from '@/lib/gemini' import { buildPaletteData } from '@/lib/search' import type { PaletteItem, PaletteAction } from '@/lib/search' import { iconByType, iconColorStyles } from '@/lib/palette-icons' const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches const MAX_HISTORY = 10 const SUGGESTED_QUESTIONS = [ "What's his NHS experience?", 'Tell me about his data skills', 'What projects has he built?', ] const buttonVariants = { hidden: prefersReducedMotion ? { opacity: 1, y: 0 } : { opacity: 0, y: 8 }, visible: { opacity: 1, y: 0, transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.3, ease: 'easeOut', delay: 1 }, }, } const panelVariants = { hidden: prefersReducedMotion ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.95 }, visible: { opacity: 1, scale: 1, transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }, }, exit: prefersReducedMotion ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.95, transition: { duration: 0.15, ease: 'easeIn' } }, } interface ChatWidgetProps { onAction?: (action: PaletteAction) => void } export function ChatWidget({ onAction }: ChatWidgetProps) { const [isOpen, setIsOpen] = useState(false) const [messages, setMessages] = useState([]) const [inputValue, setInputValue] = useState('') const [isStreaming, setIsStreaming] = useState(false) const messagesEndRef = useRef(null) const inputRef = useRef(null) const geminiAvailable = isGeminiAvailable() // Build palette map for looking up items by ID const paletteMap = useMemo(() => { const items = buildPaletteData() const map = new Map() for (const item of items) map.set(item.id, item) return map }, []) // Auto-scroll to latest message useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) // Focus input when panel opens useEffect(() => { if (isOpen) { setTimeout(() => inputRef.current?.focus(), 200) } }, [isOpen]) const handleSubmit = useCallback(async (overrideText?: string) => { const trimmed = (overrideText ?? inputValue).trim() if (!trimmed || isStreaming) return const userMessage: ChatMessage = { role: 'user', content: trimmed } const updatedMessages = [...messages, userMessage] // Cap history to last MAX_HISTORY messages, strip internal metadata const historyForApi = updatedMessages.slice(-MAX_HISTORY).map((msg) => ({ ...msg, content: msg.content.replace(/\n?/, '').trim(), })) setMessages(updatedMessages) setInputValue('') setIsStreaming(true) // Add empty assistant message that will be streamed into const assistantMessage: ChatMessage = { role: 'assistant', content: '' } setMessages((prev) => [...prev, assistantMessage]) try { const stream = sendChatMessage(historyForApi) let accumulated = '' for await (const chunk of stream) { accumulated += chunk // Update the last (assistant) message with accumulated text setMessages((prev) => { const updated = [...prev] updated[updated.length - 1] = { role: 'assistant', content: accumulated } return updated }) } // Final cleanup: strip [ITEMS: ...] suffix from display text (keep raw for parsing) // We store the clean display text but parse items from the raw accumulated text const cleanText = stripItemsSuffix(accumulated) const itemIds = parseItemIds(accumulated) const finalContent = itemIds.length > 0 ? `${cleanText}\n` : cleanText setMessages((prev) => { const updated = [...prev] updated[updated.length - 1] = { role: 'assistant', content: finalContent } return updated }) } catch { setMessages((prev) => { const updated = [...prev] updated[updated.length - 1] = { role: 'assistant', content: "Sorry, I couldn't process that. Please try again.", } return updated }) } finally { setIsStreaming(false) } }, [inputValue, isStreaming, messages]) const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSubmit() } } // Extract display text from message content (strip hidden item metadata) const getDisplayText = (content: string) => { return content.replace(/\n?/, '').trim() } // Extract item IDs from the HTML comment in message content const getMessageItemIds = (content: string): string[] => { const match = content.match(//) if (!match) return [] return match[1].split(',').map((id) => id.trim()).filter(Boolean) } // Resolve item IDs to PaletteItems const getMessageItems = (content: string): PaletteItem[] => { return getMessageItemIds(content) .map((id) => paletteMap.get(id)) .filter((item): item is PaletteItem => item !== undefined) } // Handle clicking an item card — route through onAction const handleItemClick = useCallback((item: PaletteItem) => { if (onAction) { onAction(item.action) } else { if (item.action.type === 'link') { window.open(item.action.url, '_blank', 'noopener,noreferrer') } } }, [onAction]) return ( <> {/* Chat panel */} {isOpen && (
{/* Header */}
Ask about Andy {GEMINI_DISPLAY_NAME}
{/* Messages area */}
{!geminiAvailable && (
Chat is currently unavailable.
)} {geminiAvailable && messages.length === 0 && (
{/* Welcome bubble — styled as assistant message */}
Hey! I'm here to help you learn more about Andy. What would you like to know?
{/* Suggested question chips */}
{SUGGESTED_QUESTIONS.map((question) => ( ))}
)} {messages.map((msg, i) => { const referencedItems = msg.role === 'assistant' ? getMessageItems(msg.content) : [] return (
{getDisplayText(msg.content)}
{referencedItems.length > 0 && (
{referencedItems.map((item) => { const IconComponent = iconByType[item.iconType] const colorStyle = iconColorStyles[item.iconVariant] return ( ) })}
)}
) })} {/* Typing indicator */} {isStreaming && messages.length > 0 && messages[messages.length - 1].content === '' && (
Thinking...
)}
{/* Input area */} {geminiAvailable && (