import { useCallback, useEffect, useRef, useState } from "react"; import { ArrowDown } from "lucide-react"; import { MessageBubble } from "@/components/MessageBubble"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { UIMessage } from "@/lib/types"; interface MessageListProps { messages: UIMessage[]; isStreaming: boolean; } const NEAR_BOTTOM_PX = 48; /** * Scrollable message log. Auto-sticks to the bottom as new content arrives, * but only when the user was already at the bottom — preserving scroll * position when they've scrolled up to read earlier turns. A floating * "scroll to bottom" button appears whenever we're detached from the bottom. */ export function MessageList({ messages, isStreaming }: MessageListProps) { const scrollRef = useRef(null); const [atBottom, setAtBottom] = useState(true); const scrollToBottom = useCallback((smooth = false) => { const el = scrollRef.current; if (!el) return; el.scrollTo({ top: el.scrollHeight, behavior: smooth ? "smooth" : "auto", }); }, []); // Keep the viewport pinned to the bottom as long as the user hasn't // scrolled up. During streaming we do instant jumps (smooth scrolling each // token fights the incoming animations); on settled updates we animate. useEffect(() => { if (!atBottom) return; scrollToBottom(!isStreaming); }, [messages, isStreaming, atBottom, scrollToBottom]); useEffect(() => { const el = scrollRef.current; if (!el) return; const onScroll = () => { const distance = el.scrollHeight - el.scrollTop - el.clientHeight; setAtBottom(distance < NEAR_BOTTOM_PX); }; el.addEventListener("scroll", onScroll, { passive: true }); return () => el.removeEventListener("scroll", onScroll); }, []); if (messages.length === 0) { return (
Say hi to get started.
); } return (
{messages.map((m) => ( ))}
{/* Top fade so messages slide under the header gracefully. */}
{/* Bottom fade so messages fade out behind the composer. */}
{!atBottom && ( )}
); }