Compare commits

...

1 Commits

Author SHA1 Message Date
Innei 6677f4c46c 🐛 fix(conversation): preserve scroll pin on programmatic scroll 2026-04-24 16:41:39 +08:00
3 changed files with 119 additions and 27 deletions
@@ -1,9 +1,9 @@
'use client';
import isEqual from 'fast-deep-equal';
import { type ReactElement, type ReactNode } from 'react';
import type { KeyboardEvent, ReactElement, ReactNode, TouchEvent, WheelEvent } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { type VListHandle } from 'virtua';
import type { VListHandle } from 'virtua';
import { VList } from 'virtua';
import { useShallow } from 'zustand/react/shallow';
@@ -31,6 +31,17 @@ interface VirtualizedListProps {
itemContent: (index: number, data: string) => ReactNode;
}
const USER_SCROLL_INTENT_WINDOW_MS = 800;
const USER_SCROLL_KEYS = new Set([
'ArrowDown',
'ArrowUp',
'End',
'Home',
'PageDown',
'PageUp',
' ',
]);
/**
* VirtualizedList for Conversation
*
@@ -39,6 +50,7 @@ interface VirtualizedListProps {
const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent }) => {
const virtuaRef = useRef<VListHandle>(null);
const didInitialScrollRef = useRef(false);
const lastUserScrollIntentAtRef = useRef(0);
const scrollEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const {
cancelPinMessageIndex,
@@ -89,7 +101,10 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
// Shrink spacer on scroll up when not streaming
const ref = virtuaRef.current;
if (ref) {
handleScrollOffset(ref.scrollOffset);
handleScrollOffset(ref.scrollOffset, {
isUserScrollIntent:
Date.now() - lastUserScrollIntentAtRef.current <= USER_SCROLL_INTENT_WINDOW_MS,
});
}
// Check if at bottom
@@ -182,6 +197,33 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
return [...merged].sort((a, b) => a - b);
}, [dataSource, streamingIndices, selectionMessageIds]);
const markUserScrollIntent = useCallback(() => {
lastUserScrollIntentAtRef.current = Date.now();
}, []);
const handleUserWheel = useCallback(
(event: WheelEvent<HTMLElement>) => {
if (event.ctrlKey || event.deltaY === 0) return;
markUserScrollIntent();
},
[markUserScrollIntent],
);
const handleUserTouchMove = useCallback(
(_event: TouchEvent<HTMLElement>) => {
markUserScrollIntent();
},
[markUserScrollIntent],
);
const handleUserKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
if (!USER_SCROLL_KEYS.has(event.key)) return;
markUserScrollIntent();
},
[markUserScrollIntent],
);
// Auto scroll to user message when user sends a new message
// Only scroll when 2 new messages are added and second-to-last is from user
useScrollToUserMessage({
@@ -206,7 +248,12 @@ const VirtualizedList = memo<VirtualizedListProps>(({ dataSource, itemContent })
const scrollToBottom = useConversationStore((s) => s.scrollToBottom);
return (
<div style={{ height: '100%', position: 'relative' }}>
<div
style={{ height: '100%', position: 'relative' }}
onKeyDownCapture={handleUserKeyDown}
onTouchMoveCapture={handleUserTouchMove}
onWheelCapture={handleUserWheel}
>
{/* Debug Inspector - placed outside VList so it won't be recycled by the virtual list */}
{OPEN_DEV_INSPECTOR && <DebugInspector />}
<VList
@@ -26,6 +26,7 @@ describe('useConversationSpacer helpers', () => {
hasPrevOffset: true,
isAIGenerating: true,
isMounted: true,
isUserScrollIntent: true,
}),
).toEqual({
cancelPin: true,
@@ -40,10 +41,41 @@ describe('useConversationSpacer helpers', () => {
hasPrevOffset: true,
isAIGenerating: false,
isMounted: true,
isUserScrollIntent: true,
}),
).toEqual({
cancelPin: true,
shrinkSpacer: true,
});
});
it('should keep pin retries for programmatic upward scroll while AI is still streaming', () => {
expect(
getConversationSpacerScrollEffect({
delta: -24,
hasPrevOffset: true,
isAIGenerating: true,
isMounted: true,
isUserScrollIntent: false,
}),
).toEqual({
cancelPin: false,
shrinkSpacer: false,
});
});
it('should not shrink the spacer for programmatic upward scroll after streaming stops', () => {
expect(
getConversationSpacerScrollEffect({
delta: -24,
hasPrevOffset: true,
isAIGenerating: false,
isMounted: true,
isUserScrollIntent: false,
}),
).toEqual({
cancelPin: false,
shrinkSpacer: false,
});
});
});
@@ -1,4 +1,4 @@
import { type AssistantContentBlock, type UIChatMessage } from '@lobechat/types';
import type { AssistantContentBlock, UIChatMessage } from '@lobechat/types';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { dataSelectors, messageStateSelectors, useConversationStore } from '../../store';
@@ -17,6 +17,7 @@ interface ConversationSpacerScrollEffectOptions {
hasPrevOffset: boolean;
isAIGenerating: boolean;
isMounted: boolean;
isUserScrollIntent: boolean;
}
export const getConversationSpacerScrollEffect = ({
@@ -24,8 +25,9 @@ export const getConversationSpacerScrollEffect = ({
hasPrevOffset,
isAIGenerating,
isMounted,
isUserScrollIntent,
}: ConversationSpacerScrollEffectOptions) => {
const cancelPin = isMounted && hasPrevOffset && delta < 0;
const cancelPin = isMounted && hasPrevOffset && isUserScrollIntent && delta < 0;
return {
cancelPin,
@@ -33,6 +35,10 @@ export const getConversationSpacerScrollEffect = ({
};
};
interface HandleScrollOffsetOptions {
isUserScrollIntent?: boolean;
}
const getMessageElement = (messageId: string | null) => {
if (!messageId) return null;
@@ -180,32 +186,39 @@ export const useConversationSpacer = (dataSource: string[]) => {
}, [getScrollOffset, isAIGenerating]);
// Stable scroll handler for shrinking spacer on scroll-up when not streaming
const handleScrollOffset = useCallback((currentScrollOffset: number) => {
const prevOffset = prevScrollOffsetRef.current;
prevScrollOffsetRef.current = currentScrollOffset;
const handleScrollOffset = useCallback(
(
currentScrollOffset: number,
{ isUserScrollIntent = false }: HandleScrollOffsetOptions = {},
) => {
const prevOffset = prevScrollOffsetRef.current;
prevScrollOffsetRef.current = currentScrollOffset;
const delta = prevOffset === null ? 0 : currentScrollOffset - prevOffset;
const { cancelPin, shrinkSpacer } = getConversationSpacerScrollEffect({
delta,
hasPrevOffset: prevOffset !== null,
isAIGenerating: isAIGeneratingRef.current,
isMounted: mountedRef.current,
});
const delta = prevOffset === null ? 0 : currentScrollOffset - prevOffset;
const { cancelPin, shrinkSpacer } = getConversationSpacerScrollEffect({
delta,
hasPrevOffset: prevOffset !== null,
isAIGenerating: isAIGeneratingRef.current,
isMounted: mountedRef.current,
isUserScrollIntent,
});
if (!cancelPin) return;
if (!cancelPin) return;
if (userMessageIndexRef.current !== null) {
setCancelPinMessageIndex(userMessageIndexRef.current);
}
if (!shrinkSpacer) return;
if (userMessageIndexRef.current !== null) {
setCancelPinMessageIndex(userMessageIndexRef.current);
}
if (!shrinkSpacer) return;
setScrollReduction((prev) => prev + Math.abs(delta));
setScrollReduction((prev) => prev + Math.abs(delta));
if (scrollShrinkEndTimerRef.current) clearTimeout(scrollShrinkEndTimerRef.current);
scrollShrinkEndTimerRef.current = setTimeout(() => {
scrollShrinkEndTimerRef.current = null;
}, 150);
}, []);
if (scrollShrinkEndTimerRef.current) clearTimeout(scrollShrinkEndTimerRef.current);
scrollShrinkEndTimerRef.current = setTimeout(() => {
scrollShrinkEndTimerRef.current = null;
}, 150);
},
[],
);
// Unmount when rendered height reaches zero via scroll reduction
useEffect(() => {