'use client'; import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { FiMessageSquare, FiX, FiChevronRight } from 'react-icons/fi'; import { useChatStreaming } from './useChatStreaming'; import '@chatscope/chat-ui-kit-styles/dist/default/styles.min.css'; import './chat-overrides.css'; import { MainContainer, ChatContainer, MessageList, Message, } from '@chatscope/chat-ui-kit-react'; import EvidenceBlock from './EvidenceBlock'; import { tryParseEvidence } from './parseEvidence'; import { FiSend, FiSquare } from 'react-icons/fi'; type Props = { api?: string; title?: string; hintText?: string; badgeId: string; // Optional: customize login path and header offset behavior for different host apps loginPath?: string; // e.g., '/login' in enterprise; defaults to '/auth/login' headerOffset?: 'auto' | 'none' | number; // default 'auto' (measure
), use 'none' when no header developerName?: string; // used for starter questions }; export default function ChatWidget({ api = '/api/chat', title = 'KYD Bot', hintText = 'Ask anything about the developer', badgeId, loginPath = '/auth/login', headerOffset = 'auto', developerName }: Props) { // Responsive breakpoint detection (mobile if < 768px) const [isMobile, setIsMobile] = useState(false); useEffect(() => { const check = () => setIsMobile(typeof window !== 'undefined' && window.innerWidth < 768); check(); window.addEventListener('resize', check); return () => window.removeEventListener('resize', check); }, []); // Sidebar open state (default collapsed) const [open, setOpen] = useState(false); // Avoid hydration mismatches by rendering only after mount const [hasMounted, setHasMounted] = useState(false); useEffect(() => { setHasMounted(true); }, []); // On first mount, default collapsed on mobile (unless URL prefilled opens it) useEffect(() => { if (!hasMounted) return; try { const url = typeof window !== 'undefined' ? new URL(window.location.href) : null; const prompt = url ? url.searchParams.get('chatPrompt') : null; if (!prompt && isMobile) setOpen(false); } catch {} // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasMounted, isMobile]); useEffect(() => { // when sidebar is closed, dismiss hint if (!open) setShowHint(false); }, [open]); // Sidebar width with bounds and persistence const [width, setWidth] = useState(() => { try { const w = Number(localStorage.getItem('kydChatSidebarWidth')); return Number.isFinite(w) && w >= 320 ? Math.min(w, Math.floor(window.innerWidth * 0.9)) : 420; } catch { return 420; } }); const minWidth = Math.min(320, Math.floor((typeof window !== 'undefined' ? window.innerWidth : 1200) * 0.92)); const maxWidth = Math.max(minWidth, Math.floor((typeof window !== 'undefined' ? window.innerWidth : 1200) * 0.9)); // Collapsed tab vertical position const [tabTop, setTabTop] = useState(() => { try { const t = Number(localStorage.getItem('kydChatCollapsedTop')); return Number.isFinite(t) ? t : 120; } catch { return 120; } }); const [draggingResize, setDraggingResize] = useState(false); const [draggingTab, setDraggingTab] = useState(false); const dragStartXRef = useRef(0); const startWidthRef = useRef(0); const dragStartYRef = useRef(0); const startTopRef = useRef(0); const tabDraggedRef = useRef(false); const [headerTop, setHeaderTop] = useState(0); const [showHint, setShowHint] = useState(false); const { messages, input, setInput, sending, sendMessage, cancel } = useChatStreaming({ api, badgeId, loginPath }); const listRef = useRef(null); useEffect(() => { if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight; }, [messages, open]); // Prefill input from URL chatPrompt and open the sidebar when present useEffect(() => { try { if (typeof window === 'undefined') return; const url = new URL(window.location.href); const prompt = url.searchParams.get('chatPrompt'); if (prompt && prompt.trim()) { setInput(prompt); setOpen(true); } } catch {} }, [setInput]); // Optional hint (only shows if user hasn't dismissed before and when collapsed) useEffect(() => { try { const dismissed = localStorage.getItem('kydChatHintDismissed'); if (!dismissed) { const shouldShow = !open; // show only if collapsed on first load if (shouldShow) { setShowHint(true); const t = setTimeout(() => setShowHint(false), 6000); return () => clearTimeout(t); } } } catch {} }, [open]); const onDismissHint = () => { setShowHint(false); try { localStorage.setItem('kydChatHintDismissed', '1'); } catch {} }; const header = useMemo(() => (
{title}
), [title]); // Prevent background scroll on mobile when open fullscreen useEffect(() => { if (!isMobile) return; const original = document.body.style.overflow; document.body.style.overflow = open ? 'hidden' : original; return () => { document.body.style.overflow = original; }; }, [isMobile, open]); // Persist sidebar UI state useEffect(() => { try { localStorage.setItem('kydChatSidebarOpen', open ? '1' : '0'); } catch {} }, [open]); useEffect(() => { try { localStorage.setItem('kydChatSidebarWidth', String(width)); } catch {} }, [width]); useEffect(() => { try { localStorage.setItem('kydChatCollapsedTop', String(tabTop)); } catch {} }, [tabTop]); // ESC collapses when open useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && open) setOpen(false); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [open]); // Header offset handling to keep chat below any sticky header in host app useEffect(() => { if (headerOffset === 'none') { setHeaderTop(0); setTabTop(t => Math.max(16, t)); return; } if (typeof headerOffset === 'number' && Number.isFinite(headerOffset)) { const h = Math.max(0, Math.floor(headerOffset)); setHeaderTop(h); setTabTop(t => Math.max(h + 16, t)); return; } const measure = () => { const el = document.querySelector('header'); const h = el ? Math.floor(el.getBoundingClientRect().height) : 0; setHeaderTop(h); setTabTop(t => Math.max(h + 16, t)); }; measure(); window.addEventListener('resize', measure); window.addEventListener('scroll', measure, { passive: true }); return () => { window.removeEventListener('resize', measure); window.removeEventListener('scroll', measure); }; }, [headerOffset]); // Drag to resize (left edge of sidebar) useEffect(() => { const onMove = (e: MouseEvent) => { if (!draggingResize) return; const clientX = e.clientX; const vw = window.innerWidth; const newWidth = Math.min(maxWidth, Math.max(minWidth, vw - clientX)); setWidth(newWidth); e.preventDefault(); }; const onUp = () => { if (draggingResize) setDraggingResize(false); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; if (draggingResize) { window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; } return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, [draggingResize, maxWidth]); // Drag collapsed tab vertically useEffect(() => { const onMove = (e: MouseEvent) => { if (!draggingTab) return; const delta = e.clientY - dragStartYRef.current; if (Math.abs(delta) > 3) tabDraggedRef.current = true; const minTop = headerTop + 16; const next = Math.max(minTop, Math.min(window.innerHeight - 160 - 16, startTopRef.current + delta)); setTabTop(next); e.preventDefault(); }; const onUp = () => setDraggingTab(false); if (draggingTab) { window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); document.body.style.userSelect = 'none'; } return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); if (!draggingTab) document.body.style.userSelect = ''; }; }, [draggingTab]); const nameForCopy = (developerName && developerName.trim().length > 0) ? developerName : 'this developer'; const starterQuestions: string[] = [ `Tell me about ${nameForCopy}'s experience with Python.`, `What are ${nameForCopy}'s strongest technical areas?`, `Are there any risk or sanctions flags for ${nameForCopy}?` ]; const onStarterClick = (q: string) => { if (!open) setOpen(true); if (!sending) { // Populate input for UI and send using explicit text to avoid async state issues setInput(q); sendMessage(q); } }; if (!hasMounted) return null; const ui = (
{/* Collapsed trigger */} {!open && ( isMobile ? (
{showHint && ( )}
) : (
{showHint && ( )}
) )} {/* Expanded panel */} {open && ( )}
); return typeof document !== 'undefined' ? createPortal(ui, document.body) : ui; }