Unverified 提交 d91f9953 authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

Fixing scrolling behavior in the chat panel (#2040)

closes #2038 #2055 <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Fixes chat panel scrolling (issue #2038). Auto-scroll only runs while streaming and when you’re near the bottom, and a “scroll to bottom” button appears when you scroll away. - **Bug Fixes** - Use Virtuoso scrollerRef and a conditional followOutput to control auto-scroll based on streaming state and distance from bottom. - Track distance and user scrolling to show/hide the button; clean up listeners on unmount. - Add overflow-y-auto to the messages list and a test-mode fallback that uses the container’s scroll events. <sup>Written for commit 2307453c7f1ad721d6eca9cfc5a9a24224f5da26. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> <!-- greptile_comment --> <h3>Greptile Summary</h3> Replaced custom scroll tracking logic with Virtuoso's native scroll state management to fix the issue where users couldn't scroll up during streaming. **Key Changes:** - Removed manual scroll event listeners, timeout refs, and distance calculations from `ChatPanel.tsx` - Delegated scroll state to Virtuoso's `atBottomStateChange` callback which detects when user is within 150px of bottom - Converted `followOutput` from always `"smooth"` to a function that returns `"smooth"` only when `isStreaming && isAtBottom`, otherwise returns `false` to prevent forced scrolling - This allows users to scroll up and read earlier messages while the AI generates a response, restoring the behavior that existed before PR #1993 <h3>Confidence Score: 5/5</h3> - This PR is safe to merge with minimal risk - The changes are well-architected and leverage Virtuoso's built-in functionality instead of reinventing scroll tracking logic. The fix directly addresses the reported issue by making `followOutput` conditional on user position, which is the correct approach for this library. - No files require special attention <h3>Important Files Changed</h3> | Filename | Overview | |----------|----------| | src/components/ChatPanel.tsx | Refactored scroll handling to delegate to Virtuoso's native `atBottomStateChange`, removed custom scroll tracking logic and manual scroll event listeners | | src/components/chat/MessagesList.tsx | Added `atBottomStateChange` and conditional `followOutput` to Virtuoso, allowing users to scroll up while streaming without forced auto-scroll | </details> <h3>Sequence Diagram</h3> ```mermaid sequenceDiagram participant User participant ChatPanel participant MessagesList participant Virtuoso Note over User,Virtuoso: Streaming Scenario User->>MessagesList: Scrolls to read earlier messages Virtuoso->>Virtuoso: Detects scroll position > 150px from bottom Virtuoso->>MessagesList: atBottomStateChange(false) MessagesList->>ChatPanel: onScrollStateChange(false) ChatPanel->>ChatPanel: setShowScrollButton(true) Note over Virtuoso: New message chunk arrives Virtuoso->>Virtuoso: followOutput((isAtBottom) => isAtBottom && isStreaming ? "smooth" : false) Virtuoso->>Virtuoso: Returns false (user scrolled away) Virtuoso->>Virtuoso: Does NOT auto-scroll Note over User,Virtuoso: User Returns to Bottom User->>ChatPanel: Clicks scroll-to-bottom button ChatPanel->>MessagesList: scrollToBottom("smooth") MessagesList->>Virtuoso: scrollIntoView on messagesEndRef Virtuoso->>Virtuoso: Scrolls to bottom Virtuoso->>MessagesList: atBottomStateChange(true) MessagesList->>ChatPanel: onScrollStateChange(true) ChatPanel->>ChatPanel: setShowScrollButton(false) Note over Virtuoso: Subsequent message chunks Virtuoso->>Virtuoso: followOutput returns "smooth" (isAtBottom = true) Virtuoso->>Virtuoso: Auto-scrolls smoothly ``` <!-- greptile_other_comments_section --> <!-- /greptile_comment --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Improves chat scrolling reliability across production (Virtuoso) and test modes. > > - Integrates `MessagesList` with Virtuoso `scrollerRef` and conditional `followOutput` (auto-scroll only when `isStreaming` and within ~280px of bottom), with proper listener cleanup > - Centralizes scroll tracking in `ChatPanel` using `distanceFromBottomRef` and a timeout to debounce user scrolling; toggles a "scroll to bottom" button when scrolled away > - Adds test-mode behavior: non-virtualized rendering, container scroll listeners, and manual auto-scroll near bottom > - Ensures smooth scroll-to-bottom after streaming completes; applies `overflow-y-auto` and passes new props (`onScrollerRef`, `distanceFromBottomRef`, `isUserScrolling`) between components > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2307453c7f1ad721d6eca9cfc5a9a24224f5da26. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
上级 8e87f6dc
......@@ -14,6 +14,7 @@ import { VersionPane } from "./chat/VersionPane";
import { ChatError } from "./chat/ChatError";
import { Button } from "@/components/ui/button";
import { ArrowDown } from "lucide-react";
import { useSettings } from "@/hooks/useSettings";
interface ChatPanelProps {
chatId?: number;
......@@ -32,92 +33,90 @@ export function ChatPanel({
const [error, setError] = useState<string | null>(null);
const streamCountById = useAtomValue(chatStreamCountByIdAtom);
const isStreamingById = useAtomValue(isStreamingByIdAtom);
// Reference to store the processed prompt so we don't submit it twice
const { settings } = useSettings();
const messagesEndRef = useRef<HTMLDivElement | null>(null);
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
// Scroll-related properties
const [isUserScrolling, setIsUserScrolling] = useState(false);
// Scroll-related state
const [showScrollButton, setShowScrollButton] = useState(false);
const [isUserScrolling, setIsUserScrolling] = useState(false);
// Refs for scroll tracking (both test and Virtuoso modes)
const distanceFromBottomRef = useRef<number>(0);
const userScrollTimeoutRef = useRef<number | null>(null);
const lastScrollTopRef = useRef<number>(0);
// Ref to store cleanup function for Virtuoso scroller event listener
const scrollerCleanupRef = useRef<(() => void) | null>(null);
// Ref to track previous streaming state
const prevIsStreamingRef = useRef(false);
const scrollToBottom = (behavior: ScrollBehavior = "smooth") => {
messagesEndRef.current?.scrollIntoView({ behavior });
};
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;
const container = messagesContainerRef.current;
// Unified scroll tracking handler for both test and Virtuoso modes
const handleScrollTracking = useCallback((container: HTMLElement) => {
const distanceFromBottom =
container.scrollHeight - (container.scrollTop + container.clientHeight);
distanceFromBottomRef.current = distanceFromBottom;
const scrollAwayThreshold = 150; // pixels from bottom to consider "scrolled away"
// User has scrolled away from bottom
if (distanceFromBottom > scrollAwayThreshold) {
setIsUserScrolling(true);
setShowScrollButton(true);
// Clear existing timeout
if (userScrollTimeoutRef.current) {
window.clearTimeout(userScrollTimeoutRef.current);
}
// Reset isUserScrolling after 2 seconds
userScrollTimeoutRef.current = window.setTimeout(() => {
setIsUserScrolling(false);
}, 2000); // Increased timeout to 2 seconds
}, 2000);
} else {
// User is near bottom
setIsUserScrolling(false);
setShowScrollButton(false);
}
lastScrollTopRef.current = container.scrollTop;
}, []);
// Callback to receive scrollerRef from Virtuoso (production mode)
// scrollerRef is called with the element on mount and null on unmount
const handleScrollerRef = useCallback(
(ref: HTMLElement | Window | null) => {
// Always cleanup previous listener first
if (scrollerCleanupRef.current) {
scrollerCleanupRef.current();
scrollerCleanupRef.current = null;
}
// If ref is null or window, nothing to attach to
if (!ref || ref === window) return;
const element = ref as HTMLElement;
const handleScroll = () => handleScrollTracking(element);
element.addEventListener("scroll", handleScroll, { passive: true });
// Store cleanup function for later invocation
scrollerCleanupRef.current = () => {
element.removeEventListener("scroll", handleScroll);
};
},
[handleScrollTracking],
);
useEffect(() => {
const streamCount = chatId ? (streamCountById.get(chatId) ?? 0) : 0;
console.log("streamCount - scrolling to bottom", streamCount);
scrollToBottom();
}, [
chatId,
chatId ? (streamCountById.get(chatId) ?? 0) : 0,
chatId ? (isStreamingById.get(chatId) ?? false) : false,
]);
useEffect(() => {
const container = messagesContainerRef.current;
if (container) {
container.addEventListener("scroll", handleScroll, { passive: true });
}
return () => {
if (container) {
container.removeEventListener("scroll", handleScroll);
}
if (userScrollTimeoutRef.current) {
window.clearTimeout(userScrollTimeoutRef.current);
}
};
}, [handleScroll]);
}, [chatId, chatId ? (streamCountById.get(chatId) ?? 0) : 0]);
const fetchChatMessages = useCallback(async () => {
if (!chatId) {
......@@ -139,22 +138,69 @@ export function ChatPanel({
const messages = chatId ? (messagesById.get(chatId) ?? []) : [];
const isStreaming = chatId ? (isStreamingById.get(chatId) ?? false) : false;
// Auto-scroll effect when messages change during streaming
// Scroll to bottom when streaming completes to ensure footer content is visible
useEffect(() => {
const wasStreaming = prevIsStreamingRef.current;
prevIsStreamingRef.current = isStreaming;
// When streaming transitions from true to false
if (wasStreaming && !isStreaming) {
// Double RAF ensures DOM is fully updated with footer content
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scrollToBottom("smooth");
});
});
}
}, [isStreaming]);
// Test mode only: Attach scroll listener to messagesContainerRef
// In production mode, handleScrollerRef attaches to Virtuoso's scroller
useEffect(() => {
const isTestMode = settings?.isTestMode;
if (!isTestMode) return; // Only for test mode
const container = messagesContainerRef.current;
if (!container) return;
const handleScroll = () => handleScrollTracking(container);
container.addEventListener("scroll", handleScroll, { passive: true });
return () => {
container.removeEventListener("scroll", handleScroll);
};
}, [handleScrollTracking, settings?.isTestMode, isVersionPaneOpen]);
// Test mode: Auto-scroll during streaming (280px threshold)
// Note: Virtuoso handles this via followOutput in production mode
useEffect(() => {
const isTestMode = settings?.isTestMode;
if (!isTestMode) return; // Only for test mode
if (
!isUserScrolling &&
isStreaming &&
messagesContainerRef.current &&
messages.length > 0
messagesEndRef.current &&
distanceFromBottomRef.current <= 280
) {
// Only auto-scroll if user is close to bottom
if (isNearBottom(280)) {
requestAnimationFrame(() => {
scrollToBottom("instant");
});
}
requestAnimationFrame(() => {
scrollToBottom("instant");
});
}
}, [messages, isUserScrolling, isStreaming]);
}, [messages, isUserScrolling, isStreaming, settings?.isTestMode]);
// Cleanup timeout and scroller listener on unmount
useEffect(() => {
return () => {
if (userScrollTimeoutRef.current) {
window.clearTimeout(userScrollTimeoutRef.current);
}
if (scrollerCleanupRef.current) {
scrollerCleanupRef.current();
scrollerCleanupRef.current = null;
}
};
}, []);
return (
<div className="flex flex-col h-full">
......@@ -172,6 +218,9 @@ export function ChatPanel({
messages={messages}
messagesEndRef={messagesEndRef}
ref={messagesContainerRef}
onScrollerRef={handleScrollerRef}
distanceFromBottomRef={distanceFromBottomRef}
isUserScrolling={isUserScrolling}
/>
{/* Scroll to bottom button */}
......
......@@ -25,6 +25,9 @@ import { useCountTokens } from "@/hooks/useCountTokens";
interface MessagesListProps {
messages: Message[];
messagesEndRef: React.RefObject<HTMLDivElement | null>;
onScrollerRef?: (ref: HTMLElement | Window | null) => void | (() => void);
distanceFromBottomRef?: React.MutableRefObject<number>;
isUserScrolling?: boolean;
}
// Memoize ChatMessage at module level to prevent recreation on every render
......@@ -248,7 +251,16 @@ function FooterComponent({ context }: { context?: FooterContext }) {
}
export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
function MessagesList({ messages, messagesEndRef }, ref) {
function MessagesList(
{
messages,
messagesEndRef,
onScrollerRef,
distanceFromBottomRef,
isUserScrolling,
},
ref,
) {
const appId = useAtomValue(selectedAppIdAtom);
const { versions, revertVersion } = useVersions(appId);
const { streamMessage, isStreaming } = useStreamChat();
......@@ -414,7 +426,7 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
return (
<div
className="absolute inset-0 p-4"
className="absolute inset-0 overflow-y-auto p-4"
ref={ref}
data-testid="messages-list"
>
......@@ -422,10 +434,18 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
data={messages}
increaseViewportBy={{ top: 1000, bottom: 500 }}
initialTopMostItemIndex={messages.length - 1}
followOutput="smooth"
itemContent={itemContent}
components={{ Footer: FooterComponent }}
context={footerContext}
scrollerRef={onScrollerRef}
followOutput={() => {
const shouldAutoScroll =
!isUserScrolling &&
isStreaming &&
distanceFromBottomRef &&
distanceFromBottomRef.current <= 280;
return shouldAutoScroll ? "auto" : false;
}}
/>
</div>
);
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论