Skip to content

Commit 6f06492

Browse files
committed
Simplified scroll to bottom logic
1 parent 5bfa2e7 commit 6f06492

File tree

2 files changed

+134
-65
lines changed

2 files changed

+134
-65
lines changed

‎lightrag_webui/src/features/RetrievalTesting.tsx‎

Lines changed: 101 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Input from '@/components/ui/Input'
22
import Button from '@/components/ui/Button'
33
import { useCallback, useEffect, useRef, useState } from 'react'
4+
import { throttle } from '@/lib/utils'
45
import { queryText, queryTextStream, Message } from '@/api/lightrag'
56
import { errorMessage } from '@/lib/utils'
67
import { useSettingsStore } from '@/stores/settings'
@@ -19,31 +20,28 @@ export default function RetrievalTesting() {
1920
const [isLoading, setIsLoading] = useState(false)
2021
// Reference to track if we should follow scroll during streaming (using ref for synchronous updates)
2122
const shouldFollowScrollRef = useRef(true)
22-
// Reference to track if this is the first chunk of a streaming response
23-
const isFirstChunkRef = useRef(true)
23+
// Reference to track if user interaction is from the form area
24+
const isFormInteractionRef = useRef(false)
25+
// Reference to track if scroll was triggered programmatically
26+
const programmaticScrollRef = useRef(false)
27+
// Reference to track if we're currently receiving a streaming response
28+
const isReceivingResponseRef = useRef(false)
2429
const messagesEndRef = useRef<HTMLDivElement>(null)
2530
const messagesContainerRef = useRef<HTMLDivElement>(null)
2631

27-
// Check if the container is near the bottom
28-
const isNearBottom = useCallback(() => {
29-
const container = messagesContainerRef.current
30-
if (!container) return true // Default to true if no container reference
31-
32-
// Calculate distance to bottom
33-
const { scrollTop, scrollHeight, clientHeight } = container
34-
const distanceToBottom = scrollHeight - scrollTop - clientHeight
35-
36-
// Consider near bottom if less than 100px from bottom
37-
return distanceToBottom < 100
32+
// Scroll to bottom function - restored smooth scrolling with better handling
33+
const scrollToBottom = useCallback(() => {
34+
// Set flag to indicate this is a programmatic scroll
35+
programmaticScrollRef.current = true
36+
// Use requestAnimationFrame for better performance
37+
requestAnimationFrame(() => {
38+
if (messagesEndRef.current) {
39+
// Use smooth scrolling for better user experience
40+
messagesEndRef.current.scrollIntoView({ behavior: 'auto' })
41+
}
42+
})
3843
}, [])
3944

40-
const scrollToBottom = useCallback((force = false) => {
41-
// Only scroll if forced or user is already near bottom
42-
if (force || isNearBottom()) {
43-
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
44-
}
45-
}, [isNearBottom])
46-
4745
const handleSubmit = useCallback(
4846
async (e: React.FormEvent) => {
4947
e.preventDefault()
@@ -64,15 +62,15 @@ export default function RetrievalTesting() {
6462

6563
// Add messages to chatbox
6664
setMessages([...prevMessages, userMessage, assistantMessage])
67-
68-
// Reset first chunk flag for new streaming response
69-
isFirstChunkRef.current = true
70-
// Enable follow scroll for new query
65+
66+
// Reset scroll following state for new query
7167
shouldFollowScrollRef.current = true
72-
68+
// Set flag to indicate we're receiving a response
69+
isReceivingResponseRef.current = true
70+
7371
// Force scroll to bottom after messages are rendered
7472
setTimeout(() => {
75-
scrollToBottom(true)
73+
scrollToBottom()
7674
}, 0)
7775

7876
// Clear input and set loading
@@ -81,17 +79,6 @@ export default function RetrievalTesting() {
8179

8280
// Create a function to update the assistant's message
8381
const updateAssistantMessage = (chunk: string, isError?: boolean) => {
84-
// Check if this is the first chunk of the streaming response
85-
if (isFirstChunkRef.current) {
86-
// Determine scroll behavior based on initial position
87-
shouldFollowScrollRef.current = isNearBottom();
88-
isFirstChunkRef.current = false;
89-
}
90-
91-
// Save current scroll position before updating content
92-
const container = messagesContainerRef.current;
93-
const currentScrollPosition = container ? container.scrollTop : 0;
94-
9582
assistantMessage.content += chunk
9683
setMessages((prev) => {
9784
const newMessages = [...prev]
@@ -102,19 +89,13 @@ export default function RetrievalTesting() {
10289
}
10390
return newMessages
10491
})
105-
106-
// After updating content, check if we should scroll
107-
// Use consistent scrolling behavior throughout the streaming response
92+
93+
// After updating content, scroll to bottom if auto-scroll is enabled
94+
// Use a longer delay to ensure DOM has updated
10895
if (shouldFollowScrollRef.current) {
109-
scrollToBottom(true);
110-
} else if (container) {
111-
// If user was not near bottom, restore their scroll position
112-
// This needs to be in a setTimeout to work after React updates the DOM
11396
setTimeout(() => {
114-
if (container) {
115-
container.scrollTop = currentScrollPosition;
116-
}
117-
}, 0);
97+
scrollToBottom()
98+
}, 30)
11899
}
119100
}
120101

@@ -152,6 +133,7 @@ export default function RetrievalTesting() {
152133
} finally {
153134
// Clear loading and add messages to state
154135
setIsLoading(false)
136+
isReceivingResponseRef.current = false
155137
useSettingsStore
156138
.getState()
157139
.setRetrievalHistory([...prevMessages, userMessage, assistantMessage])
@@ -160,30 +142,76 @@ export default function RetrievalTesting() {
160142
[inputValue, isLoading, messages, setMessages, t, scrollToBottom]
161143
)
162144

163-
// Add scroll event listener to detect when user manually scrolls
145+
// Add event listeners to detect when user manually interacts with the container
164146
useEffect(() => {
165147
const container = messagesContainerRef.current;
166148
if (!container) return;
167-
168-
const handleScroll = () => {
169-
const isNearBottomNow = isNearBottom();
170-
171-
// If user scrolls away from bottom while in auto-scroll mode, disable it
172-
if (shouldFollowScrollRef.current && !isNearBottomNow) {
149+
150+
// Handle significant mouse wheel events - only disable auto-scroll for deliberate scrolling
151+
const handleWheel = (e: WheelEvent) => {
152+
// Only consider significant wheel movements (more than 10px)
153+
if (Math.abs(e.deltaY) > 10 && !isFormInteractionRef.current) {
173154
shouldFollowScrollRef.current = false;
174155
}
175-
// If user scrolls back to bottom while not in auto-scroll mode, re-enable it
176-
else if (!shouldFollowScrollRef.current && isNearBottomNow) {
177-
shouldFollowScrollRef.current = true;
156+
};
157+
158+
// Handle scroll events - only disable auto-scroll if not programmatically triggered
159+
// and if it's a significant scroll
160+
const handleScroll = throttle(() => {
161+
// If this is a programmatic scroll, don't disable auto-scroll
162+
if (programmaticScrollRef.current) {
163+
programmaticScrollRef.current = false;
164+
return;
165+
}
166+
167+
// If we're receiving a response, be more conservative about disabling auto-scroll
168+
if (!isFormInteractionRef.current && !isReceivingResponseRef.current) {
169+
shouldFollowScrollRef.current = false;
178170
}
171+
}, 30);
172+
173+
// Add event listeners - only listen for wheel and scroll events
174+
container.addEventListener('wheel', handleWheel as EventListener);
175+
container.addEventListener('scroll', handleScroll as EventListener);
176+
177+
return () => {
178+
container.removeEventListener('wheel', handleWheel as EventListener);
179+
container.removeEventListener('scroll', handleScroll as EventListener);
180+
};
181+
}, []);
182+
183+
// Add event listeners to the form area to prevent disabling auto-scroll when interacting with form
184+
useEffect(() => {
185+
const form = document.querySelector('form');
186+
if (!form) return;
187+
188+
const handleFormMouseDown = () => {
189+
// Set flag to indicate form interaction
190+
isFormInteractionRef.current = true;
191+
192+
// Reset the flag after a short delay
193+
setTimeout(() => {
194+
isFormInteractionRef.current = false;
195+
}, 500); // Give enough time for the form interaction to complete
179196
};
180-
181-
container.addEventListener('scroll', handleScroll);
182-
return () => container.removeEventListener('scroll', handleScroll);
183-
}, [isNearBottom]); // Remove shouldFollowScroll from dependencies since we're using ref now
184197

185-
const debouncedMessages = useDebounce(messages, 100)
186-
useEffect(() => scrollToBottom(false), [debouncedMessages, scrollToBottom])
198+
form.addEventListener('mousedown', handleFormMouseDown);
199+
200+
return () => {
201+
form.removeEventListener('mousedown', handleFormMouseDown);
202+
};
203+
}, []);
204+
205+
// Use a longer debounce time for better performance with large message updates
206+
const debouncedMessages = useDebounce(messages, 150)
207+
useEffect(() => {
208+
// Only auto-scroll if enabled
209+
if (shouldFollowScrollRef.current) {
210+
// Force scroll to bottom when messages change
211+
scrollToBottom()
212+
}
213+
}, [debouncedMessages, scrollToBottom])
214+
187215

188216
const clearMessages = useCallback(() => {
189217
setMessages([])
@@ -194,7 +222,15 @@ export default function RetrievalTesting() {
194222
<div className="flex size-full gap-2 px-2 pb-12 overflow-hidden">
195223
<div className="flex grow flex-col gap-4">
196224
<div className="relative grow">
197-
<div ref={messagesContainerRef} className="bg-primary-foreground/60 absolute inset-0 flex flex-col overflow-auto rounded-lg border p-2">
225+
<div
226+
ref={messagesContainerRef}
227+
className="bg-primary-foreground/60 absolute inset-0 flex flex-col overflow-auto rounded-lg border p-2"
228+
onClick={() => {
229+
if (shouldFollowScrollRef.current) {
230+
shouldFollowScrollRef.current = false;
231+
}
232+
}}
233+
>
198234
<div className="flex min-h-0 flex-1 flex-col gap-2">
199235
{messages.length === 0 ? (
200236
<div className="text-muted-foreground flex h-full items-center justify-center text-lg">

‎lightrag_webui/src/lib/utils.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,39 @@ export function errorMessage(error: any) {
1919
return error instanceof Error ? error.message : `${error}`
2020
}
2121

22+
/**
23+
* Creates a throttled function that limits how often the original function can be called
24+
* @param fn The function to throttle
25+
* @param delay The delay in milliseconds
26+
* @returns A throttled version of the function
27+
*/
28+
export function throttle<T extends (...args: any[]) => any>(fn: T, delay: number): (...args: Parameters<T>) => void {
29+
let lastCall = 0
30+
let timeoutId: ReturnType<typeof setTimeout> | null = null
31+
32+
return function(this: any, ...args: Parameters<T>) {
33+
const now = Date.now()
34+
const remaining = delay - (now - lastCall)
35+
36+
if (remaining <= 0) {
37+
// If enough time has passed, execute the function immediately
38+
if (timeoutId) {
39+
clearTimeout(timeoutId)
40+
timeoutId = null
41+
}
42+
lastCall = now
43+
fn.apply(this, args)
44+
} else if (!timeoutId) {
45+
// If not enough time has passed, set a timeout to execute after the remaining time
46+
timeoutId = setTimeout(() => {
47+
lastCall = Date.now()
48+
timeoutId = null
49+
fn.apply(this, args)
50+
}, remaining)
51+
}
52+
}
53+
}
54+
2255
type WithSelectors<S> = S extends { getState: () => infer T }
2356
? S & { use: { [K in keyof T]: () => T[K] } }
2457
: never

0 commit comments

Comments
 (0)