Unverified 提交 ec2807f0 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Fix chat auto-scroll: replace timeout-based tracking with position-ba… (#2448)

…sed approach The previous stick-to-bottom mechanism used a timeout-based `isUserScrolling` flag that reset after 2 seconds, causing the chat to snap back to auto-follow even when the user intentionally scrolled away. It also used manual scroll event listeners that duplicated Virtuoso's built-in capabilities. Replace with Virtuoso's native `atBottomStateChange` callback as the single source of truth for scroll position tracking. This gives: - Reliable stick-to-bottom: follows streaming output when at bottom - Clean escape: scrolling up past 80px threshold stops auto-follow immediately - No timeout jank: purely position-based, no 2-second reset timer - Simpler code: removes manual scroll listeners, cleanup refs, and timeout logic https://claude.ai/code/session_01PC8tFKJ439W8cVaT5Efyzr <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2448"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes core chat scrolling behavior during streaming by replacing timeout-based user-scroll detection with position-based at-bottom state, which could regress auto-follow or scroll-button behavior in edge cases. Scope is limited to the chat UI and does not touch data/auth logic. > > **Overview** > Refactors chat auto-scroll to be **purely position-based**: replaces the timeout-driven `isUserScrolling`/manual scroller listeners with a single at-bottom source of truth (`atBottomStateChange` in Virtuoso, plus a simple test-mode scroll handler). > > Auto-follow now resumes only when the user is actually at the bottom, and the stream-complete “scroll to footer” behavior runs only if the user stayed at bottom during streaming; the scroll-to-bottom button is driven directly by the at-bottom state. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3273c28f3284afcde3fac58e50f243333404b799. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Fixes chat auto-scroll so the view only follows streaming when you’re at the bottom and stays put when you scroll up. Removes timeout-based tracking and manual listeners for a smoother, simpler experience. - **Bug Fixes** - Stops snap-back: scrolling up past 80px disables auto-follow immediately. - Follows streaming output only when at bottom; shows a scroll-to-bottom button when not. - After a stream finishes, auto-scrolls only if you were already at bottom. - **Refactors** - Replaced timeout-based isUserScrolling with Virtuoso’s atBottomStateChange + followOutput. - Removed manual scroll listeners and cleanup refs; rely on Virtuoso for position tracking. - Updated MessagesList to use atBottomThreshold=80 and a simplified followOutput callback; test mode mirrors behavior with lightweight scroll tracking. <sup>Written for commit a7b3fba8b0528f52b11d0a6711cec574be3732d8. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: 's avatarClaude <noreply@anthropic.com> Co-authored-by: 's avatarclaude[bot] <41898282+claude[bot]@users.noreply.github.com>
上级 1fe9bc2a
......@@ -44,85 +44,39 @@ export function ChatPanel({
const messagesEndRef = useRef<HTMLDivElement | null>(null);
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
// Scroll-related state
// Tracks whether the user is at the bottom of the scroll container.
// Uses a ref so followOutput can read it without stale closures,
// and state for the scroll button UI which needs re-renders.
const isAtBottomRef = useRef(true);
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);
// Ref to store cleanup function for Virtuoso scroller event listener
const scrollerCleanupRef = useRef<(() => void) | null>(null);
// Ref to track previous streaming state
// Ref to track previous streaming state for stream-complete scroll
const prevIsStreamingRef = useRef(false);
const scrollToBottom = (behavior: ScrollBehavior = "smooth") => {
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
messagesEndRef.current?.scrollIntoView({ behavior });
};
const handleScrollButtonClick = () => {
scrollToBottom("smooth");
};
// 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);
} else {
// User is near bottom
setIsUserScrolling(false);
setShowScrollButton(false);
}
}, []);
// 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 });
// Called by Virtuoso's atBottomStateChange (production) or scroll handler (test mode).
// Pure position-based: no timeouts, no debounce.
const handleAtBottomChange = useCallback((atBottom: boolean) => {
isAtBottomRef.current = atBottom;
setShowScrollButton(!atBottom);
}, []);
// Store cleanup function for later invocation
scrollerCleanupRef.current = () => {
element.removeEventListener("scroll", handleScroll);
};
},
[handleScrollTracking],
);
const handleScrollButtonClick = useCallback(() => {
// Optimistically mark as at-bottom so followOutput resumes immediately
isAtBottomRef.current = true;
setShowScrollButton(false);
scrollToBottom("smooth");
}, [scrollToBottom]);
// Scroll to bottom when a new stream starts (user sent a message)
const streamCount = chatId ? (streamCountById.get(chatId) ?? 0) : 0;
useEffect(() => {
const streamCount = chatId ? (streamCountById.get(chatId) ?? 0) : 0;
console.log("streamCount - scrolling to bottom", streamCount);
isAtBottomRef.current = true;
setShowScrollButton(false);
scrollToBottom();
}, [chatId, chatId ? (streamCountById.get(chatId) ?? 0) : 0]);
}, [chatId, streamCount, scrollToBottom]);
const fetchChatMessages = useCallback(async () => {
if (!chatId) {
......@@ -144,13 +98,13 @@ export function ChatPanel({
const messages = chatId ? (messagesById.get(chatId) ?? []) : [];
const isStreaming = chatId ? (isStreamingById.get(chatId) ?? false) : false;
// Scroll to bottom when streaming completes to ensure footer content is visible
// Scroll to bottom when streaming completes to ensure footer content is visible,
// but only if the user was following (at bottom) during the stream.
useEffect(() => {
const wasStreaming = prevIsStreamingRef.current;
prevIsStreamingRef.current = isStreaming;
// When streaming transitions from true to false
if (wasStreaming && !isStreaming) {
if (wasStreaming && !isStreaming && isAtBottomRef.current) {
// Double RAF ensures DOM is fully updated with footer content
requestAnimationFrame(() => {
requestAnimationFrame(() => {
......@@ -158,55 +112,37 @@ export function ChatPanel({
});
});
}
}, [isStreaming]);
}, [isStreaming, scrollToBottom]);
// Test mode only: Attach scroll listener to messagesContainerRef
// In production mode, handleScrollerRef attaches to Virtuoso's scroller
// Test mode only: Track scroll position to update isAtBottom state.
// In production, Virtuoso's atBottomStateChange handles this.
useEffect(() => {
const isTestMode = settings?.isTestMode;
if (!isTestMode) return; // Only for test mode
if (!settings?.isTestMode) return;
const container = messagesContainerRef.current;
if (!container) return;
const handleScroll = () => handleScrollTracking(container);
container.addEventListener("scroll", handleScroll, { passive: true });
return () => {
container.removeEventListener("scroll", handleScroll);
const handleScroll = () => {
const distanceFromBottom =
container.scrollHeight - (container.scrollTop + container.clientHeight);
handleAtBottomChange(distanceFromBottom <= 80);
};
}, [handleScrollTracking, settings?.isTestMode, isVersionPaneOpen]);
// Test mode: Auto-scroll during streaming (280px threshold)
// Note: Virtuoso handles this via followOutput in production mode
container.addEventListener("scroll", handleScroll, { passive: true });
return () => container.removeEventListener("scroll", handleScroll);
}, [settings?.isTestMode, isVersionPaneOpen, handleAtBottomChange]);
// Test mode: Auto-scroll during streaming when user is at the bottom.
// In production, Virtuoso's followOutput handles this.
useEffect(() => {
const isTestMode = settings?.isTestMode;
if (!isTestMode) return; // Only for test mode
if (!settings?.isTestMode) return;
if (
!isUserScrolling &&
isStreaming &&
messagesEndRef.current &&
distanceFromBottomRef.current <= 280
) {
if (isAtBottomRef.current && isStreaming) {
requestAnimationFrame(() => {
scrollToBottom("instant");
});
}
}, [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;
}
};
}, []);
}, [messages, isStreaming, settings?.isTestMode, scrollToBottom]);
return (
<div className="flex flex-col h-full">
......@@ -224,9 +160,7 @@ export function ChatPanel({
messages={messages}
messagesEndRef={messagesEndRef}
ref={messagesContainerRef}
onScrollerRef={handleScrollerRef}
distanceFromBottomRef={distanceFromBottomRef}
isUserScrolling={isUserScrolling}
onAtBottomChange={handleAtBottomChange}
/>
{/* Scroll to bottom button */}
......
......@@ -23,9 +23,7 @@ import { PromoMessage } from "./PromoMessage";
interface MessagesListProps {
messages: Message[];
messagesEndRef: React.RefObject<HTMLDivElement | null>;
onScrollerRef?: (ref: HTMLElement | Window | null) => void | (() => void);
distanceFromBottomRef?: React.MutableRefObject<number>;
isUserScrolling?: boolean;
onAtBottomChange?: (atBottom: boolean) => void;
}
// Memoize ChatMessage at module level to prevent recreation on every render
......@@ -237,16 +235,7 @@ function FooterComponent({ context }: { context?: FooterContext }) {
}
export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
function MessagesList(
{
messages,
messagesEndRef,
onScrollerRef,
distanceFromBottomRef,
isUserScrolling,
},
ref,
) {
function MessagesList({ messages, messagesEndRef, onAtBottomChange }, ref) {
const appId = useAtomValue(selectedAppIdAtom);
const { versions, revertVersion } = useVersions(appId);
const { streamMessage, isStreaming } = useStreamChat();
......@@ -416,15 +405,9 @@ export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
itemContent={itemContent}
components={{ Footer: FooterComponent }}
context={footerContext}
scrollerRef={onScrollerRef}
followOutput={() => {
const shouldAutoScroll =
!isUserScrolling &&
isStreaming &&
distanceFromBottomRef &&
distanceFromBottomRef.current <= 280;
return shouldAutoScroll ? "auto" : false;
}}
atBottomThreshold={80}
atBottomStateChange={onAtBottomChange}
followOutput={(isAtBottom) => (isAtBottom ? "auto" : false)}
/>
</div>
);
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论