Add scroll to bottom button (#1484)
Based on #1425 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds a floating scroll-to-bottom button and refines scroll/auto-scroll behavior with layout tweaks to support an overlay. > > - **Chat UI**: > - **Scroll-to-bottom button**: Adds floating button in `ChatPanel` (uses `Button` and `ArrowDown`) that appears when scrolled away and scrolls smoothly to the latest message. > - **Scroll logic**: Introduces `getDistanceFromBottom`, `isNearBottom`, and a `scrollAwayThreshold`; auto-scroll now triggers only when near the bottom; refactors `handleScroll` with `useCallback` and longer idle timeout. > - **Layout**: Wraps `MessagesList` in a `relative` container and renders a centered absolute button overlay; adjusts `MessagesList` root to `absolute inset-0` for proper overlay behavior. > - **Misc**: Updates effect dependencies and console log message related to streaming/scrolling. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2e1b844830ae26cfc40840b9e8216fefad112a5e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Md Rakibul Islam Rocky <mdrirocky08@outlook.com>
This commit is contained in:
@@ -11,6 +11,8 @@ import { MessagesList } from "./chat/MessagesList";
|
|||||||
import { ChatInput } from "./chat/ChatInput";
|
import { ChatInput } from "./chat/ChatInput";
|
||||||
import { VersionPane } from "./chat/VersionPane";
|
import { VersionPane } from "./chat/VersionPane";
|
||||||
import { ChatError } from "./chat/ChatError";
|
import { ChatError } from "./chat/ChatError";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ArrowDown } from "lucide-react";
|
||||||
|
|
||||||
interface ChatPanelProps {
|
interface ChatPanelProps {
|
||||||
chatId?: number;
|
chatId?: number;
|
||||||
@@ -35,21 +37,44 @@ export function ChatPanel({
|
|||||||
|
|
||||||
// Scroll-related properties
|
// Scroll-related properties
|
||||||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||||||
|
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||||
const userScrollTimeoutRef = useRef<number | null>(null);
|
const userScrollTimeoutRef = useRef<number | null>(null);
|
||||||
const lastScrollTopRef = useRef<number>(0);
|
const lastScrollTopRef = useRef<number>(0);
|
||||||
|
|
||||||
const scrollToBottom = (behavior: ScrollBehavior = "smooth") => {
|
const scrollToBottom = (behavior: ScrollBehavior = "smooth") => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior });
|
messagesEndRef.current?.scrollIntoView({ behavior });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScrollButtonClick = () => {
|
||||||
|
if (!messagesContainerRef.current) return;
|
||||||
|
|
||||||
|
scrollToBottom("smooth");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDistanceFromBottom = () => {
|
||||||
|
if (!messagesContainerRef.current) return 0;
|
||||||
|
const container = messagesContainerRef.current;
|
||||||
|
return (
|
||||||
|
container.scrollHeight - (container.scrollTop + container.clientHeight)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNearBottom = (threshold: number = 100) => {
|
||||||
|
return getDistanceFromBottom() <= threshold;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollAwayThreshold = 150; // pixels from bottom to consider "scrolled away"
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
if (!messagesContainerRef.current) return;
|
if (!messagesContainerRef.current) return;
|
||||||
|
|
||||||
const container = messagesContainerRef.current;
|
const container = messagesContainerRef.current;
|
||||||
const currentScrollTop = container.scrollTop;
|
const distanceFromBottom =
|
||||||
|
container.scrollHeight - (container.scrollTop + container.clientHeight);
|
||||||
|
|
||||||
if (currentScrollTop < lastScrollTopRef.current) {
|
// User has scrolled away from bottom
|
||||||
|
if (distanceFromBottom > scrollAwayThreshold) {
|
||||||
setIsUserScrolling(true);
|
setIsUserScrolling(true);
|
||||||
|
setShowScrollButton(true);
|
||||||
|
|
||||||
if (userScrollTimeoutRef.current) {
|
if (userScrollTimeoutRef.current) {
|
||||||
window.clearTimeout(userScrollTimeoutRef.current);
|
window.clearTimeout(userScrollTimeoutRef.current);
|
||||||
@@ -57,15 +82,18 @@ export function ChatPanel({
|
|||||||
|
|
||||||
userScrollTimeoutRef.current = window.setTimeout(() => {
|
userScrollTimeoutRef.current = window.setTimeout(() => {
|
||||||
setIsUserScrolling(false);
|
setIsUserScrolling(false);
|
||||||
}, 1000);
|
}, 2000); // Increased timeout to 2 seconds
|
||||||
|
} else {
|
||||||
|
// User is near bottom
|
||||||
|
setIsUserScrolling(false);
|
||||||
|
setShowScrollButton(false);
|
||||||
}
|
}
|
||||||
|
lastScrollTopRef.current = container.scrollTop;
|
||||||
lastScrollTopRef.current = currentScrollTop;
|
}, []);
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const streamCount = chatId ? (streamCountById.get(chatId) ?? 0) : 0;
|
const streamCount = chatId ? (streamCountById.get(chatId) ?? 0) : 0;
|
||||||
console.log("streamCount", streamCount);
|
console.log("streamCount - scrolling to bottom", streamCount);
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [chatId, chatId ? (streamCountById.get(chatId) ?? 0) : 0]);
|
}, [chatId, chatId ? (streamCountById.get(chatId) ?? 0) : 0]);
|
||||||
|
|
||||||
@@ -83,7 +111,7 @@ export function ChatPanel({
|
|||||||
window.clearTimeout(userScrollTimeoutRef.current);
|
window.clearTimeout(userScrollTimeoutRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, [handleScroll]);
|
||||||
|
|
||||||
const fetchChatMessages = useCallback(async () => {
|
const fetchChatMessages = useCallback(async () => {
|
||||||
if (!chatId) {
|
if (!chatId) {
|
||||||
@@ -110,13 +138,8 @@ export function ChatPanel({
|
|||||||
messagesContainerRef.current &&
|
messagesContainerRef.current &&
|
||||||
messages.length > 0
|
messages.length > 0
|
||||||
) {
|
) {
|
||||||
const { scrollTop, clientHeight, scrollHeight } =
|
// Only auto-scroll if user is close to bottom
|
||||||
messagesContainerRef.current;
|
if (isNearBottom(280)) {
|
||||||
const threshold = 280;
|
|
||||||
const isNearBottom =
|
|
||||||
scrollHeight - (scrollTop + clientHeight) <= threshold;
|
|
||||||
|
|
||||||
if (isNearBottom) {
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
scrollToBottom("instant");
|
scrollToBottom("instant");
|
||||||
});
|
});
|
||||||
@@ -135,11 +158,29 @@ export function ChatPanel({
|
|||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{!isVersionPaneOpen && (
|
{!isVersionPaneOpen && (
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
<MessagesList
|
<div className="flex-1 relative overflow-hidden">
|
||||||
messages={messages}
|
<MessagesList
|
||||||
messagesEndRef={messagesEndRef}
|
messages={messages}
|
||||||
ref={messagesContainerRef}
|
messagesEndRef={messagesEndRef}
|
||||||
/>
|
ref={messagesContainerRef}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Scroll to bottom button */}
|
||||||
|
{showScrollButton && (
|
||||||
|
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-10">
|
||||||
|
<Button
|
||||||
|
onClick={handleScrollButtonClick}
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full shadow-lg hover:shadow-xl transition-all border border-border/50 backdrop-blur-sm bg-background/95 hover:bg-accent"
|
||||||
|
variant="outline"
|
||||||
|
title={"Scroll to bottom"}
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ChatError error={error} onDismiss={() => setError(null)} />
|
<ChatError error={error} onDismiss={() => setError(null)} />
|
||||||
<ChatInput chatId={chatId} />
|
<ChatInput chatId={chatId} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex-1 overflow-y-auto p-4"
|
className="absolute inset-0 overflow-y-auto p-4"
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-testid="messages-list"
|
data-testid="messages-list"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user