From 420e9d3dd562f9bd174e1aa234ca3bd8c739f848 Mon Sep 17 00:00:00 2001 From: ManishMadan2882 Date: Fri, 10 Apr 2026 19:25:44 +0530 Subject: [PATCH] (feat) conversation: scroll experience --- frontend/src/components/MermaidRenderer.tsx | 6 +- frontend/src/components/MessageInput.tsx | 9 +- frontend/src/conversation/Conversation.tsx | 3 +- .../src/conversation/ConversationBubble.tsx | 4 +- .../src/conversation/ConversationMessages.tsx | 196 ++++++++++++++---- 5 files changed, 164 insertions(+), 54 deletions(-) diff --git a/frontend/src/components/MermaidRenderer.tsx b/frontend/src/components/MermaidRenderer.tsx index 7dc93c49..95c004c7 100644 --- a/frontend/src/components/MermaidRenderer.tsx +++ b/frontend/src/components/MermaidRenderer.tsx @@ -263,8 +263,8 @@ const MermaidRenderer: React.FC = ({ const errorRender = !isCurrentlyLoading && error; return ( -
-
+
+
mermaid @@ -401,7 +401,7 @@ const MermaidRenderer: React.FC = ({ {showCode && (
-
+
Mermaid Code diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx index d30650ed..3d52a75b 100644 --- a/frontend/src/components/MessageInput.tsx +++ b/frontend/src/components/MessageInput.tsx @@ -1296,9 +1296,8 @@ export default function MessageInput({ }, []); useEffect(() => { - if (autoFocus) inputRef.current?.focus(); handleInput(); - }, [autoFocus, handleInput]); + }, [handleInput]); const handleChange = (e: React.ChangeEvent) => { setValue(e.target.value); @@ -1364,8 +1363,9 @@ export default function MessageInput({ ) { onSubmit(value); setValue(''); - // Refocus input after submission if autoFocus is enabled - if (autoFocus) { + if (isTouch) { + inputRef.current?.blur(); + } else if (autoFocus) { setTimeout(() => { if (isMountedRef.current) { inputRef.current?.focus(); @@ -1544,6 +1544,7 @@ export default function MessageInput({ id="message-input" ref={inputRef} value={value} + autoFocus={autoFocus && !isTouch} onChange={handleChange} readOnly={ recordingState === 'recording' || diff --git a/frontend/src/conversation/Conversation.tsx b/frontend/src/conversation/Conversation.tsx index 179417be..684d6e55 100644 --- a/frontend/src/conversation/Conversation.tsx +++ b/frontend/src/conversation/Conversation.tsx @@ -236,7 +236,7 @@ export default function Conversation() { isSplitArtifactOpen ? 'w-[60%] px-6' : 'w-full' }`} > -
+
+
-
+
{language} @@ -1204,7 +1204,7 @@ function Thought({ return match ? (
-
+
{language} diff --git a/frontend/src/conversation/ConversationMessages.tsx b/frontend/src/conversation/ConversationMessages.tsx index 7747e7b8..158fda1b 100644 --- a/frontend/src/conversation/ConversationMessages.tsx +++ b/frontend/src/conversation/ConversationMessages.tsx @@ -62,40 +62,130 @@ export default function ConversationMessages({ const { t } = useTranslation(); const conversationRef = useRef(null); - const [hasScrolledToLast, setHasScrolledToLast] = useState(true); - const [userInterruptedScroll, setUserInterruptedScroll] = useState(false); + const [scrollButtonVisible, setScrollButtonVisible] = useState(false); + const userInterruptedRef = useRef(false); + const [interrupted, setInterrupted] = useState(false); + const lastTouchYRef = useRef(null); + const isInitialLoad = useRef(true); + const prevQueriesRef = useRef(queries); + const isAutoScrollingRef = useRef(false); + const smoothScrollTimeoutRef = + useRef>(undefined); + const showButtonTimerRef = useRef>(undefined); - const handleUserScrollInterruption = useCallback(() => { - if (!userInterruptedScroll && status === 'loading') { - setUserInterruptedScroll(true); - } - }, [userInterruptedScroll, status]); + useEffect(() => { + return () => { + clearTimeout(smoothScrollTimeoutRef.current); + clearTimeout(showButtonTimerRef.current); + }; + }, []); - const scrollConversationToBottom = useCallback(() => { - if (!conversationRef.current || userInterruptedScroll) return; + const isAtBottom = useCallback(() => { + const el = conversationRef.current; + if (!el) return true; + return el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD; + }, []); - requestAnimationFrame(() => { - if (!conversationRef?.current) return; + // Arm on upward scroll intent; requiring !isAtBottom() missed small nudges still inside SCROLL_THRESHOLD. + const markInterruptedIfLoading = useCallback(() => { + if (userInterruptedRef.current || status !== 'loading') return; + userInterruptedRef.current = true; + setInterrupted(true); + }, [status]); - if (status === 'idle' || !queries[queries.length - 1]?.response) { - conversationRef.current.scrollTo({ - behavior: 'smooth', - top: conversationRef.current.scrollHeight, - }); - } else { - conversationRef.current.scrollTop = - conversationRef.current.scrollHeight; + const handleWheel = useCallback( + (e: React.WheelEvent) => { + if (e.deltaY < 0) markInterruptedIfLoading(); + }, + [markInterruptedIfLoading], + ); + + const handleTouchStart = useCallback((e: React.TouchEvent) => { + lastTouchYRef.current = e.touches[0].clientY; + }, []); + + const handleTouchMove = useCallback( + (e: React.TouchEvent) => { + const y = e.touches[0].clientY; + if (lastTouchYRef.current !== null && y > lastTouchYRef.current) { + markInterruptedIfLoading(); } - }); - }, [userInterruptedScroll, status, queries]); + lastTouchYRef.current = y; + }, + [markInterruptedIfLoading], + ); - const checkScrollPosition = useCallback(() => { + const setButtonHidden = useCallback(() => { + clearTimeout(showButtonTimerRef.current); + showButtonTimerRef.current = undefined; + setScrollButtonVisible(false); + }, []); + + const setButtonVisibleDebounced = useCallback(() => { + if (showButtonTimerRef.current) return; + showButtonTimerRef.current = setTimeout(() => { + setScrollButtonVisible(true); + showButtonTimerRef.current = undefined; + }, 300); + }, []); + + const scrollConversationToBottom = useCallback( + (instant?: boolean) => { + if (!conversationRef.current) return; + + isAutoScrollingRef.current = true; + clearTimeout(smoothScrollTimeoutRef.current); + + requestAnimationFrame(() => { + if (!conversationRef?.current) return; + + if (instant) { + conversationRef.current.scrollTop = + conversationRef.current.scrollHeight; + if (isAtBottom()) { + setButtonHidden(); + } + isAutoScrollingRef.current = false; + } else { + conversationRef.current.scrollTo({ + behavior: 'smooth', + top: conversationRef.current.scrollHeight, + }); + smoothScrollTimeoutRef.current = setTimeout(() => { + if (isAtBottom()) { + setButtonHidden(); + } + isAutoScrollingRef.current = false; + }, 500); + } + }); + }, + [isAtBottom, setButtonHidden], + ); + + const handleScroll = useCallback(() => { const el = conversationRef.current; if (!el) return; - const isAtBottom = - el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD; - setHasScrolledToLast(isAtBottom); - }, [setHasScrolledToLast]); + + const atBottom = isAtBottom(); + + if (atBottom && userInterruptedRef.current) { + userInterruptedRef.current = false; + setInterrupted(false); + } + + if (atBottom) { + setButtonHidden(); + isAutoScrollingRef.current = false; + return; + } + + if (isAutoScrollingRef.current) { + return; + } + + setButtonVisibleDebounced(); + }, [isAtBottom, setButtonHidden, setButtonVisibleDebounced]); const lastQuery = queries[queries.length - 1]; const lastQueryResponse = lastQuery?.response; @@ -103,34 +193,46 @@ export default function ConversationMessages({ const lastQueryThought = lastQuery?.thought; useEffect(() => { - if (!userInterruptedScroll) { - scrollConversationToBottom(); + if (interrupted) return; + + const prevQueries = prevQueriesRef.current; + const isConversationSwitch = + prevQueries !== queries && prevQueries[0] !== queries[0]; + + if (isInitialLoad.current || isConversationSwitch) { + isInitialLoad.current = false; + scrollConversationToBottom(true); + prevQueriesRef.current = queries; + return; } + + const isNewMessage = queries.length > prevQueries.length; + prevQueriesRef.current = queries; + + scrollConversationToBottom(isNewMessage ? false : true); }, [ queries.length, lastQueryResponse, lastQueryError, lastQueryThought, - userInterruptedScroll, + interrupted, scrollConversationToBottom, ]); useEffect(() => { if (status === 'idle') { - setUserInterruptedScroll(false); + userInterruptedRef.current = false; + setInterrupted(false); } }, [status]); useEffect(() => { const currentConversationRef = conversationRef.current; - currentConversationRef?.addEventListener('scroll', checkScrollPosition); + currentConversationRef?.addEventListener('scroll', handleScroll); return () => { - currentConversationRef?.removeEventListener( - 'scroll', - checkScrollPosition, - ); + currentConversationRef?.removeEventListener('scroll', handleScroll); }; - }, [checkScrollPosition]); + }, [handleScroll]); const retryIconProps = { width: 12, @@ -208,7 +310,7 @@ export default function ConversationMessages({ >
-
+
{t('conversation.answer')} - {queries.length > 0 && !hasScrolledToLast && ( + {queries.length > 0 && (