import { useCallback, useEffect, useRef, useState } from 'react'; import type { ChatMessage, ToolActivity } from '../../hooks/useChat'; import MessageBubble from './MessageBubble'; import TypingIndicator from './TypingIndicator'; import ImageLightbox, { type LightboxImage } from './ImageLightbox'; interface Props { messages: ChatMessage[]; streaming: boolean; streamBuffer: string; tools: ToolActivity[]; hasMore?: boolean; onLoadOlder?: () => void; } export default function MessageList({ messages, streaming, streamBuffer, tools, hasMore, onLoadOlder }: Props) { const bottomRef = useRef(null); const scrollContainerRef = useRef(null); const sentinelRef = useRef(null); const isInitialLoad = useRef(true); const [loadingOlder, setLoadingOlder] = useState(false); const [lightbox, setLightbox] = useState<{ images: LightboxImage[]; index: number } | null>(null); const handleImageClick = useCallback((images: LightboxImage[], index: number) => { setLightbox({ images, index }); }, []); // Scroll to bottom — instant on initial load, smooth afterwards useEffect(() => { if (!bottomRef.current) return; if (isInitialLoad.current && messages.length > 0) { bottomRef.current.scrollIntoView({ behavior: 'instant' as ScrollBehavior }); isInitialLoad.current = false; } else if (!isInitialLoad.current) { bottomRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [messages, streamBuffer, tools, streaming]); // IntersectionObserver for infinite scroll up useEffect(() => { if (!sentinelRef.current || !onLoadOlder) return; const sentinel = sentinelRef.current; const observer = new IntersectionObserver( async (entries) => { if (entries[0].isIntersecting && hasMore && !loadingOlder) { const container = scrollContainerRef.current; if (!container) return; // Save scroll position before loading const prevScrollHeight = container.scrollHeight; setLoadingOlder(true); await onLoadOlder(); setLoadingOlder(false); // Restore scroll position after DOM update requestAnimationFrame(() => { const newScrollHeight = container.scrollHeight; container.scrollTop += newScrollHeight - prevScrollHeight; }); } }, { root: scrollContainerRef.current, threshold: 0.1 }, ); observer.observe(sentinel); return () => observer.disconnect(); }, [hasMore, loadingOlder, onLoadOlder]); // Get the latest running tool name (only the current one) const currentTool = tools.length > 0 ? tools[tools.length - 1] : null; const currentToolName = currentTool?.status === 'running' ? currentTool.name : undefined; return (
{/* Spacer pushes messages to the bottom when few */}
{/* Sentinel for infinite scroll + loading spinner */} {hasMore && (
{loadingOlder && (
)}
)} {messages.length === 0 && !streaming && (
Start a conversation with your Bloby..
)}
{messages.map((msg) => ( ))} {/* Typing / streaming bubble — shown as soon as agent starts. Strip the leading "[Mac] " / "[Alexa] " / etc. that the agent sometimes echoes. */} {streaming && ( [\s\S]*?<\/notch_card>/gi, '') .replace(/[\s\S]*?<\/notch_html>/gi, '') .replace(/ Mac screen actions (complete blocks, then a trailing unclosed one) .replace(/[\s\S]*?<\/morphy_action>/gi, '') .replace(/ JSON-array blocks .replace(/[\s\S]*?<\/mac_actions>/gi, '') .replace(/ )}
{lightbox && ( setLightbox(null)} onNavigate={(i) => setLightbox((prev) => prev ? { ...prev, index: i } : null)} /> )}
); }