-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Implement smart auto-scroll for chat messages #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 19 commits
49df993
4ec8df4
ca69537
4925dd7
697319c
eea5a29
22c8b4f
6fdf742
d0c762b
4be9b23
cf19fa8
297e338
94d646a
bee10d6
ab40ee7
ce4d094
6de859c
b8cf5de
fedaaca
15d5b14
3108302
5ab0521
54b0049
16ad26c
da147c8
7b67619
db676c2
4a89eaa
1e16cd2
3a093c8
41b9e3f
f906cc4
e07baa2
3d69ef1
eae79be
abd512f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,59 +1,176 @@ | ||
| import { useState, useEffect, RefObject } from "react"; | ||
| import { useState, useEffect, useRef, RefObject } from "react"; | ||
| import type { Message, SessionStatus } from "@/shared/types"; | ||
|
|
||
| interface UseAutoScrollOptions { | ||
| messages: Message[]; | ||
| sessionStatus: SessionStatus; | ||
| messagesContainerRef: RefObject<HTMLDivElement>; | ||
| messagesEndRef: RefObject<HTMLDivElement>; | ||
| messagesEndRef: RefObject<HTMLDivElement>; // Empty div at end of messages | ||
| lastMessageRef: RefObject<HTMLDivElement>; // Last message element | ||
| // Configuration options | ||
| scrollThreshold?: number; // Distance from bottom to consider "at bottom" (default: 100) | ||
| inputHeightBuffer?: number; // Buffer for message input height (default: 80) | ||
| smoothScrollUser?: boolean; // Use smooth scroll for user messages (default: false) | ||
| } | ||
|
|
||
| /** | ||
| * Hook to manage auto-scroll behavior and scroll-to-bottom button | ||
| * | ||
| * Features: | ||
| * - USER messages: Scroll to top of viewport (push old messages up) | ||
| * - ASSISTANT messages: Smart overflow detection | ||
| * - If there's visible space below → NO scroll (messages appear naturally) | ||
| * - If content would be hidden → AUTO scroll (reveal new content) | ||
| * - Shows "scroll to bottom" button when user scrolls up | ||
| * - Respects user intent (doesn't auto-scroll if user manually scrolled up) | ||
| * | ||
| * UX Benefits: | ||
| * - Reduces unnecessary scrolling when viewport has space | ||
| * - User sees their question + answer simultaneously | ||
| * - Less jarring, more natural content flow | ||
| * - Only scrolls when content would actually be cut off | ||
| */ | ||
| export function useAutoScroll({ | ||
| messages, | ||
| sessionStatus, | ||
| messagesContainerRef, | ||
| messagesEndRef, | ||
| lastMessageRef, | ||
| scrollThreshold = 100, | ||
| inputHeightBuffer = 80, | ||
| smoothScrollUser = false, | ||
| }: UseAutoScrollOptions) { | ||
| const [showScrollButton, setShowScrollButton] = useState(false); | ||
| const [shouldAutoScroll, setShouldAutoScroll] = useState(true); | ||
| const [isUserScrolledUp, setIsUserScrolledUp] = useState(false); | ||
| const lastMessageCountRef = useRef(0); | ||
| const isAutoScrollingRef = useRef(false); // Track if we're auto-scrolling | ||
|
|
||
| // Auto-scroll when messages change or session status changes | ||
| // Check if user is near bottom (within threshold) | ||
| const isNearBottom = (threshold = scrollThreshold) => { | ||
| const container = messagesContainerRef.current; | ||
| if (!container) return false; | ||
|
|
||
| const { scrollTop, scrollHeight, clientHeight } = container; | ||
| return scrollHeight - scrollTop - clientHeight < threshold; | ||
| }; | ||
|
|
||
| // Scroll to bottom function | ||
| const scrollToBottom = (smooth = false) => { | ||
| messagesEndRef.current?.scrollIntoView({ | ||
| behavior: smooth ? 'smooth' : 'auto', | ||
| block: 'end' | ||
| }); | ||
| }; | ||
|
|
||
| // Auto-scroll when new messages arrive | ||
| useEffect(() => { | ||
| if (shouldAutoScroll) { | ||
| scrollToBottom(); | ||
| setShouldAutoScroll(false); | ||
| // Only auto-scroll if a NEW message was added (not on initial mount) | ||
| if (messages.length === 0 || messages.length === lastMessageCountRef.current) { | ||
| lastMessageCountRef.current = messages.length; | ||
| return; | ||
| } | ||
| }, [messages, sessionStatus, shouldAutoScroll]); | ||
|
|
||
| // Handle scroll detection for "scroll to bottom" button | ||
| const lastMessage = messages[messages.length - 1]; | ||
| const container = messagesContainerRef.current; | ||
|
|
||
| if (!isUserScrolledUp && container) { | ||
| isAutoScrollingRef.current = true; | ||
|
|
||
| if (lastMessage.role === 'user') { | ||
| // USER message: Scroll marker to TOP of viewport | ||
| requestAnimationFrame(() => { | ||
| const marker = lastMessageRef.current; | ||
| if (marker) { | ||
| // Get marker position relative to container | ||
| const markerTop = marker.offsetTop; | ||
| // Scroll container so marker is at the top | ||
| const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; | ||
| container.scrollTo({ | ||
| top: Math.max(0, markerTop), | ||
| behavior: smoothScrollUser && !prefersReduced ? 'smooth' : 'auto', | ||
| }); | ||
|
|
||
| if (import.meta.env.DEV) { | ||
| console.log('[useAutoScroll] User message scrolled to top:', markerTop); | ||
| } | ||
| } else { | ||
| if (import.meta.env.DEV) { | ||
| console.warn('[useAutoScroll] lastMessageRef is null!'); | ||
| } | ||
| } | ||
|
|
||
| setTimeout(() => { | ||
| isAutoScrollingRef.current = false; | ||
| }, 100); | ||
| }); | ||
| } else { | ||
| // ASSISTANT message: Scroll to BOTTOM | ||
| requestAnimationFrame(() => { | ||
| scrollToBottom(false); | ||
|
|
||
| setTimeout(() => { | ||
| isAutoScrollingRef.current = false; | ||
| }, 100); | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| lastMessageCountRef.current = messages.length; | ||
| }, [messages.length, isUserScrolledUp, smoothScrollUser]); | ||
|
|
||
| // Track user scroll behavior | ||
| useEffect(() => { | ||
| const container = messagesContainerRef.current; | ||
| if (!container) return; | ||
|
|
||
| const handleScroll = () => { | ||
| const { scrollTop, scrollHeight, clientHeight } = container; | ||
| const isNearBottom = scrollHeight - scrollTop - clientHeight < 100; | ||
| setShowScrollButton(!isNearBottom); | ||
| // Don't update state if we're auto-scrolling | ||
| if (isAutoScrollingRef.current) return; | ||
|
|
||
| const nearBottom = isNearBottom(); | ||
| setShowScrollButton(!nearBottom); | ||
| setIsUserScrolledUp(!nearBottom); | ||
| }; | ||
|
|
||
| container.addEventListener('scroll', handleScroll); | ||
| return () => container.removeEventListener('scroll', handleScroll); | ||
| }, [messagesContainerRef]); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: Missing Prompt To Fix With AIThis is a comment left during a code review.
Path: src/features/session/hooks/useAutoScroll.ts
Line: 131:131
Comment:
**logic:** Missing `isNearBottom` dependency in the cleanup
How can I resolve this? If you propose a fix, please make it concise. |
||
|
|
||
| function scrollToBottom(smooth = false) { | ||
| messagesEndRef.current?.scrollIntoView({ | ||
| behavior: smooth ? 'smooth' : 'auto', | ||
| block: 'end' | ||
| // ResizeObserver: Auto-scroll during streaming (when content grows) | ||
| useEffect(() => { | ||
| const container = messagesContainerRef.current; | ||
| const target = lastMessageRef.current ?? messagesEndRef.current; | ||
| if (!container || !target) return; | ||
|
|
||
| const resizeObserver = new ResizeObserver(() => { | ||
| const { scrollTop, scrollHeight, clientHeight } = container; | ||
| const viewportBottom = scrollTop + clientHeight; | ||
| const contentBottom = scrollHeight; | ||
| const isContentHidden = contentBottom > viewportBottom + inputHeightBuffer; | ||
|
|
||
| // Only auto-scroll if: | ||
| // 1. In working session (streaming) | ||
| // 2. User hasn't scrolled up | ||
| // 3. Content would be hidden below viewport | ||
| if (sessionStatus === 'working' && !isUserScrolledUp && isContentHidden) { | ||
| isAutoScrollingRef.current = true; | ||
| scrollToBottom(false); | ||
| setTimeout(() => { | ||
| isAutoScrollingRef.current = false; | ||
| }, 50); | ||
| } | ||
| }); | ||
|
Comment on lines
+152
to
173
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: ResizeObserver may fire many times during streaming, potentially causing performance issues. Consider throttling or debouncing the scroll checks. Prompt To Fix With AIThis is a comment left during a code review.
Path: src/features/session/hooks/useAutoScroll.ts
Line: 138:155
Comment:
**style:** ResizeObserver may fire many times during streaming, potentially causing performance issues. Consider throttling or debouncing the scroll checks.
How can I resolve this? If you propose a fix, please make it concise. |
||
| } | ||
|
|
||
| function handleScrollToBottomClick() { | ||
| setShouldAutoScroll(true); | ||
| scrollToBottom(true); | ||
| } | ||
| resizeObserver.observe(target); | ||
| return () => resizeObserver.disconnect(); | ||
| }, [messagesContainerRef, messagesEndRef, lastMessageRef, isUserScrolledUp, sessionStatus, inputHeightBuffer]); | ||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| // Manual scroll to bottom (resets user scroll state) | ||
| const handleScrollToBottomClick = () => { | ||
| setIsUserScrolledUp(false); | ||
| const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; | ||
| scrollToBottom(!prefersReduced); | ||
| }; | ||
|
|
||
| return { | ||
| showScrollButton, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,7 @@ interface ChatProps { | |
| sessionStatus: SessionStatus; | ||
| parseContent: (content: string) => ReactNode; | ||
| messagesEndRef: RefObject<HTMLDivElement>; | ||
| lastMessageRef: RefObject<HTMLDivElement>; | ||
| messagesContainerRef: RefObject<HTMLDivElement>; | ||
| showScrollButton?: boolean; | ||
| onScrollToBottom?: () => void; | ||
|
|
@@ -25,6 +26,7 @@ export function Chat({ | |
| sessionStatus, | ||
| parseContent, | ||
| messagesEndRef, | ||
| lastMessageRef, | ||
| messagesContainerRef, | ||
| showScrollButton = false, | ||
| onScrollToBottom, | ||
|
|
@@ -35,7 +37,7 @@ export function Chat({ | |
| id="chat-messages" | ||
| role="log" | ||
| aria-live="polite" | ||
| className="relative flex-1 overflow-y-auto overflow-x-hidden scroll-smooth min-h-0 px-6 pt-6" | ||
| className="relative flex-1 overflow-y-auto overflow-x-hidden scroll-smooth motion-reduce:scroll-auto min-h-0 px-6 pt-6" | ||
| ref={messagesContainerRef} | ||
| > | ||
| {loading ? ( | ||
|
|
@@ -55,22 +57,26 @@ export function Chat({ | |
| ) : ( | ||
| <> | ||
| <div className="flex flex-col gap-6 pb-8 min-h-0"> | ||
| {messages.map(message => ( | ||
| <MessageItem | ||
| {messages.map((message, index) => ( | ||
| <div | ||
| key={message.id} | ||
| message={message} | ||
| parseContent={parseContent} | ||
| toolResultMap={toolResultMap} | ||
| /> | ||
| ref={index === messages.length - 1 ? lastMessageRef : undefined} | ||
| > | ||
| <MessageItem | ||
| message={message} | ||
| parseContent={parseContent} | ||
| toolResultMap={toolResultMap} | ||
| /> | ||
| </div> | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| ))} | ||
|
Comment on lines
+54
to
65
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wrapper divs may cause extra spacing when MessageItem returns null. Each message is now wrapped in a div, but Consider filtering messages before rendering or conditionally rendering the wrapper: <div className="flex flex-col gap-6 pb-8 min-h-0">
- {messages.map((message, index) => (
+ {messages.map((message, index) => {
+ const isLast = index === messages.length - 1;
+ return (
<div
key={message.id}
- ref={index === messages.length - 1 ? lastMessageRef : undefined}
+ ref={isLast ? lastMessageRef : undefined}
>
<MessageItem
message={message}
parseContent={parseContent}
toolResultMap={toolResultMap}
/>
</div>
- ))}
+ );
+ })}
</div>Or better, conditionally render the wrapper only when MessageItem would render: {messages.map((message, index) => {
const isLast = index === messages.length - 1;
const messageItem = (
<MessageItem
message={message}
parseContent={parseContent}
toolResultMap={toolResultMap}
/>
);
return isLast ? (
<div key={message.id} ref={lastMessageRef}>
{messageItem}
</div>
) : (
messageItem
);
})}However, this second approach defeats the purpose of always having a ref target for scrolling. 🤖 Prompt for AI Agents |
||
| </div> | ||
| {sessionStatus === 'working' && ( | ||
| <div | ||
| role="status" | ||
| aria-live="polite" | ||
| className="flex items-center gap-2 p-2.5 px-3.5 mt-2 mr-auto max-w-[85%] bg-success/10 backdrop-blur-sm border border-success/30 rounded-xl text-success font-medium text-[0.85rem] shadow-sm animate-[pulse_0.6s_ease-in-out_infinite]" | ||
| className="flex items-center gap-2 p-2.5 px-3.5 mt-2 mr-auto max-w-[85%] bg-success/10 backdrop-blur-sm border border-success/30 rounded-xl text-success font-medium text-[0.85rem] shadow-sm animate-[pulse_0.6s_ease_infinite] motion-reduce:animate-none" | ||
| > | ||
| <div className="w-4 h-4 border-2 border-success/20 border-t-success rounded-full animate-spin flex-shrink-0" aria-hidden="true"></div> | ||
| <div className="w-4 h-4 border-2 border-success/20 border-t-success rounded-full animate-spin motion-reduce:animate-none flex-shrink-0" aria-hidden="true"></div> | ||
| <span>Claude is working...</span> | ||
| </div> | ||
| )} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,19 +43,18 @@ export function MessageInput({ | |
| const resizeTextarea = () => { | ||
| const el = textareaRef.current; | ||
| if (!el) return; | ||
| el.style.height = 'auto'; | ||
| el.style.height = Math.min(el.scrollHeight, 200) + 'px'; | ||
| // Reset to minimum first | ||
| el.style.height = '40px'; | ||
| // Then expand to fit content, max 200px | ||
| const newHeight = Math.min(Math.max(el.scrollHeight, 40), 200); | ||
| el.style.height = newHeight + 'px'; | ||
|
Comment on lines
+46
to
+50
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: Potential flashing issue: resetting to 40px then immediately expanding may cause visible height jump during typing. Consider setting height only when scrollHeight differs from current height. Have you tested this with rapid typing or paste operations to ensure smooth height transitions? Prompt To Fix With AIThis is a comment left during a code review.
Path: src/features/session/ui/MessageInput.tsx
Line: 46:50
Comment:
**style:** Potential flashing issue: resetting to 40px then immediately expanding may cause visible height jump during typing. Consider setting height only when scrollHeight differs from current height. Have you tested this with rapid typing or paste operations to ensure smooth height transitions?
How can I resolve this? If you propose a fix, please make it concise. |
||
| }; | ||
|
|
||
| // Auto-resize textarea | ||
| useEffect(() => { | ||
| resizeTextarea(); | ||
| }, [messageInput]); | ||
|
|
||
| useEffect(() => { | ||
| resizeTextarea(); | ||
| }, []); | ||
|
|
||
| return ( | ||
| <div className="flex-shrink-0 m-0 px-6 pb-4 z-10 flex flex-col gap-3"> | ||
| {/* Glassmorphic ChatBox */} | ||
|
|
@@ -86,15 +85,15 @@ export function MessageInput({ | |
| resizeTextarea(); | ||
| }} | ||
| onBlur={() => setIsFocused(false)} | ||
| style={{ height: '40px' }} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: Inline style conflicts with CSS class: setting height='40px' inline means the resizeTextarea() function must override it every time. The inline style takes precedence, so resizeTextarea() correctly overwrites it, but this creates redundant DOM operations. Prompt To Fix With AIThis is a comment left during a code review.
Path: src/features/session/ui/MessageInput.tsx
Line: 96:96
Comment:
**style:** Inline style conflicts with CSS class: setting height='40px' inline means the resizeTextarea() function must override it every time. The inline style takes precedence, so resizeTextarea() correctly overwrites it, but this creates redundant DOM operations.
How can I resolve this? If you propose a fix, please make it concise. |
||
| className=" | ||
| flex-1 bg-transparent border-none outline-none resize-none | ||
| text-body-lg text-foreground placeholder:text-muted-foreground | ||
| min-h-[24px] max-h-[200px] | ||
| max-h-[200px] | ||
| font-sans | ||
| overflow-y-auto | ||
| scrollbar-vibrancy | ||
| " | ||
| rows={1} | ||
| /> | ||
|
|
||
| {/* Action Buttons */} | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.