返回 JoyAI-Echo
MessageList.tsx
1 import { useCallback, useEffect, useRef, useState } from "react";
2 import { ArrowDown } from "lucide-react";
3
4 import { MessageBubble } from "@/components/MessageBubble";
5 import { Button } from "@/components/ui/button";
6 import { cn } from "@/lib/utils";
7 import type { UIMessage } from "@/lib/types";
8
9 interface MessageListProps {
10 messages: UIMessage[];
11 isStreaming: boolean;
12 }
13
14 const NEAR_BOTTOM_PX = 48;
15
16 /**
17 * Scrollable message log. Auto-sticks to the bottom as new content arrives,
18 * but only when the user was already at the bottom — preserving scroll
19 * position when they've scrolled up to read earlier turns. A floating
20 * "scroll to bottom" button appears whenever we're detached from the bottom.
21 */
22 export function MessageList({ messages, isStreaming }: MessageListProps) {
23 const scrollRef = useRef<HTMLDivElement>(null);
24 const [atBottom, setAtBottom] = useState(true);
25
26 const scrollToBottom = useCallback((smooth = false) => {
27 const el = scrollRef.current;
28 if (!el) return;
29 el.scrollTo({
30 top: el.scrollHeight,
31 behavior: smooth ? "smooth" : "auto",
32 });
33 }, []);
34
35 // Keep the viewport pinned to the bottom as long as the user hasn't
36 // scrolled up. During streaming we do instant jumps (smooth scrolling each
37 // token fights the incoming animations); on settled updates we animate.
38 useEffect(() => {
39 if (!atBottom) return;
40 scrollToBottom(!isStreaming);
41 }, [messages, isStreaming, atBottom, scrollToBottom]);
42
43 useEffect(() => {
44 const el = scrollRef.current;
45 if (!el) return;
46 const onScroll = () => {
47 const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
48 setAtBottom(distance < NEAR_BOTTOM_PX);
49 };
50 el.addEventListener("scroll", onScroll, { passive: true });
51 return () => el.removeEventListener("scroll", onScroll);
52 }, []);
53
54 if (messages.length === 0) {
55 return (
56 <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
57 Say hi to get started.
58 </div>
59 );
60 }
61
62 return (
63 <div className="relative flex min-h-0 flex-1 overflow-hidden">
64 <div
65 ref={scrollRef}
66 className={cn(
67 "h-full overflow-y-auto scroll-smooth",
68 "[&::-webkit-scrollbar]:w-1.5",
69 "[&::-webkit-scrollbar-thumb]:rounded-full",
70 "[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
71 "[&::-webkit-scrollbar-track]:bg-transparent",
72 )}
73 >
74 <div className="mx-auto flex w-full max-w-[64rem] flex-col gap-6 px-4 pt-4 pb-8">
75 {messages.map((m) => (
76 <MessageBubble key={m.id} message={m} />
77 ))}
78 </div>
79 </div>
80
81 {/* Top fade so messages slide under the header gracefully. */}
82 <div
83 aria-hidden
84 className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
85 />
86 {/* Bottom fade so messages fade out behind the composer. */}
87 <div
88 aria-hidden
89 className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-background to-transparent"
90 />
91
92 {!atBottom && (
93 <Button
94 variant="outline"
95 size="icon"
96 onClick={() => scrollToBottom(true)}
97 className={cn(
98 "absolute bottom-2 left-1/2 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
99 "bg-background/90 backdrop-blur",
100 "animate-in fade-in-0 zoom-in-95",
101 )}
102 aria-label="Scroll to bottom"
103 >
104 <ArrowDown className="h-4 w-4" />
105 </Button>
106 )}
107 </div>
108 );
109 }
110
110 lines Plain Text