Skip to content

Commit 50eb9f2

Browse files
authored
fix(tarko): improve scroll-to-bottom indicator edge case handling (#1429)
1 parent cbe3894 commit 50eb9f2

2 files changed

Lines changed: 37 additions & 70 deletions

File tree

multimodal/tarko/agent-web-ui/src/standalone/chat/components/ScrollToBottomButton.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ show
4242
w-8 h-8
4343
bg-white/70 dark:bg-gray-900/70
4444
hover:bg-white/80 dark:hover:bg-gray-900/80
45+
border border-[#F2F3F5]/60 dark:border-gray-900/60
4546
rounded-full
4647
shadow-lg hover:shadow-xl
4748
backdrop-blur-md
Lines changed: 36 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
12
import { useRef, useEffect, useState, useCallback } from 'react';
23
import { useAtomValue } from 'jotai';
34
import { replayStateAtom } from '@/common/state/atoms/replay';
45

56
// Constants
67
const SCROLL_CHECK_DELAY = 100; // ms - delay for DOM updates
7-
const SCROLL_ANIMATION_DELAY = 300; // ms - delay to account for smooth scroll animation
88
const REPLAY_AUTO_SCROLL_DELAY = 50; // ms - delay for auto-scroll in replay mode
99

1010
interface UseScrollToBottomOptions {
@@ -24,7 +24,7 @@ interface UseScrollToBottomReturn {
2424

2525
/**
2626
* Custom hook for managing scroll-to-bottom indicator in chat
27-
*
27+
*
2828
* Features:
2929
* - Shows scroll-to-bottom indicator when user has scrolled up
3030
* - Manual scroll to bottom functionality
@@ -41,72 +41,37 @@ export const useScrollToBottom = ({
4141
const messagesContainerRef = useRef<HTMLDivElement>(null);
4242
const messagesEndRef = useRef<HTMLDivElement>(null);
4343
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
44-
const isScrollingRef = useRef(false);
4544
const lastSessionIdRef = useRef<string | undefined>(sessionId);
4645
const replayState = useAtomValue(replayStateAtom);
4746
const lastEventIndexRef = useRef<number>(-1);
4847
const lastMessageCountRef = useRef<number>(0);
4948
const lastUserMessageIdRef = useRef<string | null>(null);
5049

51-
// Check if container is at bottom
52-
const checkIsAtBottom = useCallback(() => {
53-
const container = messagesContainerRef.current;
54-
if (!container) return false;
55-
56-
const { scrollTop, scrollHeight, clientHeight } = container;
57-
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
58-
59-
// Account for sub-pixel differences and rounding errors
60-
return distanceFromBottom <= Math.max(threshold, 3);
61-
}, [threshold]);
62-
6350
// Handle scroll events
6451
const handleScroll = useCallback(() => {
6552
const container = messagesContainerRef.current;
6653
if (!container) return;
67-
68-
// Don't update state during programmatic scrolling to prevent flickering
69-
if (isScrollingRef.current) return;
70-
54+
7155
const { scrollTop, scrollHeight, clientHeight } = container;
7256
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
73-
74-
// More robust bottom detection with better tolerance for rounding errors
75-
const atBottom = distanceFromBottom <= Math.max(threshold, 3);
76-
77-
// Only show button when:
78-
// 1. NOT at bottom
79-
// 2. There's scrollable content (scrollHeight > clientHeight)
80-
// 3. User has actually scrolled up (not just a minor difference)
81-
const hasScrollableContent = scrollHeight > clientHeight + 5;
82-
const hasScrolledUp = distanceFromBottom > 10; // Must be meaningfully away from bottom
83-
const shouldShow = !atBottom && hasScrollableContent && hasScrolledUp;
84-
85-
setShowScrollToBottom(shouldShow);
57+
58+
// Simple, robust bottom detection
59+
const atBottom = distanceFromBottom <= threshold;
60+
const hasScrollableContent = scrollHeight > clientHeight + 10;
61+
62+
setShowScrollToBottom(!atBottom && hasScrollableContent);
8663
}, [threshold]);
8764

88-
// Smooth scroll to bottom
89-
const scrollToBottom = useCallback((forceCheck = true) => {
65+
// Scroll to bottom
66+
const scrollToBottom = useCallback(() => {
9067
const container = messagesContainerRef.current;
9168
if (!container) return;
92-
93-
isScrollingRef.current = true;
94-
69+
9570
container.scrollTo({
9671
top: container.scrollHeight,
97-
behavior: 'smooth'
72+
behavior: 'smooth',
9873
});
99-
100-
// Reset scrolling flag after animation completes
101-
setTimeout(() => {
102-
isScrollingRef.current = false;
103-
// Force check scroll position to ensure button hides when at bottom
104-
if (forceCheck) handleScroll();
105-
}, SCROLL_ANIMATION_DELAY);
106-
}, [handleScroll]);
107-
108-
// Auto-scroll to bottom (reuse scrollToBottom without force check)
109-
const autoScrollToBottom = useCallback(() => scrollToBottom(false), [scrollToBottom]);
74+
}, []);
11075

11176
// Delayed scroll check helper
11277
const scheduleScrollCheck = useCallback(() => {
@@ -121,13 +86,12 @@ export const useScrollToBottom = ({
12186
if (sessionId !== lastSessionIdRef.current) {
12287
lastSessionIdRef.current = sessionId;
12388
setShowScrollToBottom(false);
124-
isScrollingRef.current = false;
125-
89+
12690
// Schedule a check after session content loads
12791
const timer = setTimeout(() => {
12892
handleScroll();
12993
}, SCROLL_CHECK_DELAY * 2);
130-
94+
13195
return () => clearTimeout(timer);
13296
}
13397
}, [sessionId, handleScroll]);
@@ -136,12 +100,12 @@ export const useScrollToBottom = ({
136100
useEffect(() => {
137101
const container = messagesContainerRef.current;
138102
if (!container) return;
139-
103+
140104
container.addEventListener('scroll', handleScroll, { passive: true });
141-
105+
142106
// Initial check
143107
const timer = scheduleScrollCheck();
144-
108+
145109
return () => {
146110
container.removeEventListener('scroll', handleScroll);
147111
clearTimeout(timer);
@@ -165,15 +129,15 @@ export const useScrollToBottom = ({
165129
// This covers both sequential playback and manual jumps/seeks
166130
if (replayState.currentEventIndex !== lastEventIndexRef.current) {
167131
lastEventIndexRef.current = replayState.currentEventIndex;
168-
132+
169133
// Schedule auto-scroll after DOM updates
170134
const timer = setTimeout(() => {
171-
autoScrollToBottom();
135+
scrollToBottom();
172136
}, REPLAY_AUTO_SCROLL_DELAY);
173-
137+
174138
return () => clearTimeout(timer);
175139
}
176-
}, [isReplayMode, replayState.isActive, replayState.currentEventIndex, autoScrollToBottom]);
140+
}, [isReplayMode, replayState.isActive, replayState.currentEventIndex, scrollToBottom]);
177141

178142
// Auto-scroll for user messages in normal mode
179143
useEffect(() => {
@@ -183,7 +147,7 @@ export const useScrollToBottom = ({
183147

184148
const messages = dependencies[0] as any[];
185149
const currentMessageCount = messages.length;
186-
150+
187151
// Only check for new user messages when message count increases
188152
if (currentMessageCount <= lastMessageCountRef.current) {
189153
lastMessageCountRef.current = currentMessageCount;
@@ -194,29 +158,31 @@ export const useScrollToBottom = ({
194158
const allUserMessages = messages
195159
.flatMap((group: any) => group?.messages || [group])
196160
.filter((msg: any) => msg?.role === 'user');
197-
161+
198162
const latestUserMessage = allUserMessages[allUserMessages.length - 1];
199-
163+
200164
// Auto-scroll ONLY if:
201165
// 1. We have a new user message
202166
// 2. It's the LAST user message (most recent one)
203167
// 3. It has the isLocalMessage flag (indicating it was just sent by user)
204-
if (latestUserMessage?.id &&
205-
latestUserMessage.id !== lastUserMessageIdRef.current &&
206-
latestUserMessage.isLocalMessage) {
168+
if (
169+
latestUserMessage?.id &&
170+
latestUserMessage.id !== lastUserMessageIdRef.current &&
171+
latestUserMessage.isLocalMessage
172+
) {
207173
lastUserMessageIdRef.current = latestUserMessage.id;
208-
209-
const timer = setTimeout(autoScrollToBottom, SCROLL_CHECK_DELAY);
174+
175+
const timer = setTimeout(scrollToBottom, SCROLL_CHECK_DELAY);
210176
return () => clearTimeout(timer);
211177
}
212-
178+
213179
lastMessageCountRef.current = currentMessageCount;
214-
}, [autoScrollOnUserMessage, isReplayMode, autoScrollToBottom, ...dependencies]);
180+
}, [autoScrollOnUserMessage, isReplayMode, scrollToBottom, ...dependencies]);
215181

216182
return {
217183
messagesContainerRef,
218184
messagesEndRef,
219185
showScrollToBottom,
220-
scrollToBottom: () => scrollToBottom(),
186+
scrollToBottom,
221187
};
222188
};

0 commit comments

Comments
 (0)