Skip to content

Commit 85b6dd4

Browse files
authored
feat(tarko): add intelligent auto-scroll to chat UI (#1203)
1 parent 898914f commit 85b6dd4

3 files changed

Lines changed: 244 additions & 4 deletions

File tree

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

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { replayStateAtom } from '@/common/state/atoms/replay';
1111
import { useReplayMode } from '@/common/hooks/useReplayMode';
1212
import { useReplay } from '@/common/hooks/useReplay';
1313
import { getAgentTitle } from '@/common/constants';
14+
import { useAutoScroll } from './hooks/useAutoScroll';
15+
import { ScrollToBottomButton } from './components/ScrollToBottomButton';
1416

1517
import './ChatPanel.css';
1618
import { ResearchReportEntry } from './ResearchReportEntry';
@@ -76,12 +78,23 @@ export const ChatPanel: React.FC = () => {
7678
const replayState = useAtomValue(replayStateAtom);
7779
const { isReplayMode, cancelAutoPlay } = useReplayMode();
7880

79-
const messagesEndRef = useRef<HTMLDivElement>(null);
80-
const messagesContainerRef = useRef<HTMLDivElement>(null);
81-
8281
// Use messages from current session
8382
const activeMessages = activeSessionId ? groupedMessages[activeSessionId] || [] : [];
8483

84+
// Auto-scroll functionality
85+
const {
86+
messagesContainerRef,
87+
messagesEndRef,
88+
showScrollToBottom,
89+
scrollToBottom,
90+
isUserScrolling,
91+
} = useAutoScroll({
92+
threshold: 100,
93+
debounceMs: 150,
94+
autoScrollDelay: 2000,
95+
dependencies: [activeMessages, isProcessing], // Trigger scroll on message changes
96+
});
97+
8598
// Animation variants
8699
const containerVariants = {
87100
hidden: { opacity: 0 },
@@ -333,7 +346,9 @@ export const ChatPanel: React.FC = () => {
333346
<div ref={messagesEndRef} />
334347
</div>
335348

336-
<div className="p-4">
349+
<div className="p-4 relative">
350+
{/* Scroll to bottom button - positioned relative to input area */}
351+
<ScrollToBottomButton show={showScrollToBottom} onClick={scrollToBottom} />
337352
{researchReport && !isProcessing && (
338353
<div className="mb-4">
339354
<ResearchReportEntry
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import React from 'react';
2+
import { motion, AnimatePresence } from 'framer-motion';
3+
import { FiChevronDown } from 'react-icons/fi';
4+
5+
interface ScrollToBottomButtonProps {
6+
show: boolean;
7+
onClick: () => void;
8+
}
9+
10+
/**
11+
* ScrollToBottomButton Component - Floating button to scroll to bottom of chat
12+
*
13+
* Features:
14+
* - Smooth fade in/out animation
15+
* - Gradient background for visibility
16+
* - Hover and tap animations
17+
* - Positioned above the input area
18+
*/
19+
export const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ show, onClick }) => {
20+
return (
21+
<AnimatePresence>
22+
{show && (
23+
<motion.div
24+
initial={{ opacity: 0, y: 10, scale: 0.9 }}
25+
animate={{ opacity: 1, y: 0, scale: 1 }}
26+
exit={{ opacity: 0, y: 10, scale: 0.9 }}
27+
transition={{ duration: 0.2, ease: 'easeOut' }}
28+
className="absolute -top-16 right-4 z-50"
29+
>
30+
{/* Gradient backdrop for better visibility */}
31+
<div className="absolute inset-0 bg-gradient-to-t from-white/80 via-white/60 to-transparent dark:from-gray-900/80 dark:via-gray-900/60 dark:to-transparent rounded-full blur-sm scale-110" />
32+
33+
<motion.button
34+
whileHover={{ scale: 1.05, y: -2 }}
35+
whileTap={{ scale: 0.95 }}
36+
onClick={onClick}
37+
className="relative flex items-center justify-center w-10 h-10 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full shadow-lg border border-gray-200/50 dark:border-gray-600/50 transition-colors group"
38+
aria-label="Scroll to bottom"
39+
>
40+
<motion.div
41+
animate={{ y: [0, 2, 0] }}
42+
transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
43+
>
44+
<FiChevronDown
45+
size={18}
46+
className="text-gray-600 dark:text-gray-300 group-hover:text-gray-800 dark:group-hover:text-gray-100 transition-colors"
47+
/>
48+
</motion.div>
49+
50+
{/* Subtle pulse effect */}
51+
<motion.div
52+
animate={{ scale: [1, 1.2, 1], opacity: [0.3, 0, 0.3] }}
53+
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }}
54+
className="absolute inset-0 bg-accent-500/20 dark:bg-accent-400/20 rounded-full"
55+
/>
56+
</motion.button>
57+
</motion.div>
58+
)}
59+
</AnimatePresence>
60+
);
61+
};
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { useRef, useEffect, useState, useCallback } from 'react';
2+
3+
interface UseAutoScrollOptions {
4+
threshold?: number; // Distance from bottom to consider "at bottom"
5+
debounceMs?: number; // Debounce time for user interaction detection
6+
autoScrollDelay?: number; // Delay before auto-scrolling after user stops interacting
7+
dependencies?: any[]; // Dependencies to trigger auto-scroll (e.g., messages)
8+
}
9+
10+
interface UseAutoScrollReturn {
11+
messagesContainerRef: React.RefObject<HTMLDivElement>;
12+
messagesEndRef: React.RefObject<HTMLDivElement>;
13+
showScrollToBottom: boolean;
14+
scrollToBottom: () => void;
15+
isUserScrolling: boolean;
16+
}
17+
18+
/**
19+
* Custom hook for managing intelligent auto-scroll behavior in chat
20+
*
21+
* Features:
22+
* - Auto-scrolls to bottom when new content appears
23+
* - Detects user manual scrolling and respects it
24+
* - Shows scroll-to-bottom indicator when user has scrolled up
25+
* - Automatically resumes auto-scroll after user inactivity
26+
*/
27+
export const useAutoScroll = ({
28+
threshold = 100,
29+
debounceMs = 1000,
30+
autoScrollDelay = 2000,
31+
dependencies = [],
32+
}: UseAutoScrollOptions = {}): UseAutoScrollReturn => {
33+
const messagesContainerRef = useRef<HTMLDivElement>(null);
34+
const messagesEndRef = useRef<HTMLDivElement>(null);
35+
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
36+
const [isUserScrolling, setIsUserScrolling] = useState(false);
37+
const [isAtBottom, setIsAtBottom] = useState(true);
38+
39+
const userInteractionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
40+
const lastScrollTopRef = useRef<number>(0);
41+
const isAutoScrollingRef = useRef(false);
42+
43+
// Check if container is at bottom
44+
const checkIsAtBottom = useCallback(() => {
45+
const container = messagesContainerRef.current;
46+
if (!container) return false;
47+
48+
const { scrollTop, scrollHeight, clientHeight } = container;
49+
return scrollHeight - scrollTop - clientHeight <= threshold;
50+
}, [threshold]);
51+
52+
// Smooth scroll to bottom
53+
const scrollToBottom = useCallback(() => {
54+
const container = messagesContainerRef.current;
55+
if (!container) return;
56+
57+
isAutoScrollingRef.current = true;
58+
container.scrollTo({
59+
top: container.scrollHeight,
60+
behavior: 'smooth'
61+
});
62+
63+
// Reset auto-scrolling flag after animation
64+
setTimeout(() => {
65+
isAutoScrollingRef.current = false;
66+
}, 500);
67+
}, []);
68+
69+
// Handle scroll events
70+
const handleScroll = useCallback(() => {
71+
const container = messagesContainerRef.current;
72+
if (!container || isAutoScrollingRef.current) return;
73+
74+
const currentScrollTop = container.scrollTop;
75+
const atBottom = checkIsAtBottom();
76+
77+
setIsAtBottom(atBottom);
78+
setShowScrollToBottom(!atBottom);
79+
80+
// Detect user scrolling (not programmatic)
81+
const isUserInitiated = Math.abs(currentScrollTop - lastScrollTopRef.current) > 1;
82+
83+
if (isUserInitiated) {
84+
setIsUserScrolling(true);
85+
86+
// Clear existing timeout
87+
if (userInteractionTimeoutRef.current) {
88+
clearTimeout(userInteractionTimeoutRef.current);
89+
}
90+
91+
// Set timeout to resume auto-scroll after inactivity
92+
userInteractionTimeoutRef.current = setTimeout(() => {
93+
setIsUserScrolling(false);
94+
// If still at bottom after timeout, hide the indicator
95+
if (checkIsAtBottom()) {
96+
setShowScrollToBottom(false);
97+
}
98+
}, autoScrollDelay);
99+
}
100+
101+
lastScrollTopRef.current = currentScrollTop;
102+
}, [checkIsAtBottom, autoScrollDelay]);
103+
104+
// Immediate scroll handler for real-time updates
105+
useEffect(() => {
106+
const container = messagesContainerRef.current;
107+
if (!container) return;
108+
109+
let scrollTimeout: NodeJS.Timeout;
110+
111+
const immediateHandleScroll = () => {
112+
handleScroll();
113+
};
114+
115+
const debouncedHandleScroll = () => {
116+
clearTimeout(scrollTimeout);
117+
scrollTimeout = setTimeout(handleScroll, debounceMs);
118+
};
119+
120+
// Use immediate handler for better responsiveness
121+
container.addEventListener('scroll', immediateHandleScroll, { passive: true });
122+
123+
return () => {
124+
container.removeEventListener('scroll', immediateHandleScroll);
125+
clearTimeout(scrollTimeout);
126+
};
127+
}, [handleScroll, debounceMs]);
128+
129+
// Auto-scroll to bottom when new content appears (if user hasn't scrolled up)
130+
useEffect(() => {
131+
if (!isUserScrolling && isAtBottom) {
132+
// Use requestAnimationFrame to ensure DOM has updated
133+
requestAnimationFrame(() => {
134+
scrollToBottom();
135+
});
136+
}
137+
}, [isUserScrolling, isAtBottom, scrollToBottom, ...dependencies]);
138+
139+
// Initial scroll to bottom when component mounts
140+
useEffect(() => {
141+
const timer = setTimeout(() => {
142+
scrollToBottom();
143+
}, 100);
144+
145+
return () => clearTimeout(timer);
146+
}, []);
147+
148+
// Cleanup timeouts on unmount
149+
useEffect(() => {
150+
return () => {
151+
if (userInteractionTimeoutRef.current) {
152+
clearTimeout(userInteractionTimeoutRef.current);
153+
}
154+
};
155+
}, []);
156+
157+
return {
158+
messagesContainerRef,
159+
messagesEndRef,
160+
showScrollToBottom,
161+
scrollToBottom,
162+
isUserScrolling,
163+
};
164+
};

0 commit comments

Comments
 (0)