From d78ddeadd0a37ed799d0c17171d3ccdad807b509 Mon Sep 17 00:00:00 2001 From: ManishMadan2882 Date: Sun, 12 Jul 2026 20:27:36 +0530 Subject: [PATCH] (feat)chats: shadcn msg scroller, fix jitter --- frontend/package-lock.json | 19 + frontend/package.json | 1 + .../src/components/ui/message-scroller.tsx | 137 +++++++ frontend/src/conversation/Conversation.tsx | 16 +- .../src/conversation/ConversationBubble.tsx | 8 +- .../src/conversation/ConversationMessages.tsx | 337 +++++------------- 6 files changed, 266 insertions(+), 252 deletions(-) create mode 100644 frontend/src/components/ui/message-scroller.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c419d28d..a87ef346 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@reduxjs/toolkit": "^2.12.0", + "@shadcn/react": "^0.2.1", "chart.js": "^4.4.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -2746,6 +2747,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@shadcn/react": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@shadcn/react/-/react-0.2.1.tgz", + "integrity": "sha512-5krgi3dRMKb5jH6a+qPzVJUy/54s0kKE4Rw4LjDfLqOdVQTWKUgxWf1kW8r912I0jX/Lzxqc+pgjkjWxUIK5BQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": ">=19", + "react": ">=19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 20f4bfb2..aa81daa0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@reduxjs/toolkit": "^2.12.0", + "@shadcn/react": "0.2.1", "chart.js": "^4.4.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/frontend/src/components/ui/message-scroller.tsx b/frontend/src/components/ui/message-scroller.tsx new file mode 100644 index 00000000..b7d97b61 --- /dev/null +++ b/frontend/src/components/ui/message-scroller.tsx @@ -0,0 +1,137 @@ +import * as React from 'react'; +import { + MessageScroller as MessageScrollerPrimitive, + useMessageScroller, + useMessageScrollerScrollable, + useMessageScrollerVisibility, +} from '@shadcn/react/message-scroller'; +import { ArrowDownIcon } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; + +function MessageScrollerProvider( + props: React.ComponentProps, +) { + return ; +} + +function MessageScroller({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function MessageScrollerViewport({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function MessageScrollerContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function MessageScrollerItem({ + className, + scrollAnchor = false, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function MessageScrollerButton({ + direction = 'end', + className, + children, + render, + variant = 'secondary', + size = 'icon-sm', + ...props +}: React.ComponentProps & + Pick, 'variant' | 'size'>) { + return ( + } + {...props} + > + {children ?? ( + <> + + + {direction === 'end' ? 'Scroll to end' : 'Scroll to start'} + + + )} + + ); +} + +export { + MessageScrollerProvider, + MessageScroller, + MessageScrollerViewport, + MessageScrollerContent, + MessageScrollerItem, + MessageScrollerButton, + useMessageScroller, + useMessageScrollerScrollable, + useMessageScrollerVisibility, +}; diff --git a/frontend/src/conversation/Conversation.tsx b/frontend/src/conversation/Conversation.tsx index 5f66fbdb..7a88cace 100644 --- a/frontend/src/conversation/Conversation.tsx +++ b/frontend/src/conversation/Conversation.tsx @@ -137,6 +137,19 @@ export default function Conversation() { toolName: string; } | null>(null); + const [conversationMountKey, setConversationMountKey] = useState(0); + const [prevMountConversationId, setPrevMountConversationId] = useState< + string | null + >(conversationId); + if (prevMountConversationId !== conversationId) { + const isServerAssignedId = + prevMountConversationId === null && + conversationId !== null && + isNewChatRoute; + setPrevMountConversationId(conversationId); + if (!isServerAssignedId) setConversationMountKey((k) => k + 1); + } + useEffect(() => { const prevId = prevConversationId.current; // Don't reset when the backend assigns the conversation id mid-stream (null -> id) @@ -324,6 +337,7 @@ export default function Conversation() { >
{ handleQuestionSubmission(text); }} diff --git a/frontend/src/conversation/ConversationBubble.tsx b/frontend/src/conversation/ConversationBubble.tsx index c6a56476..e0b20c60 100644 --- a/frontend/src/conversation/ConversationBubble.tsx +++ b/frontend/src/conversation/ConversationBubble.tsx @@ -1200,7 +1200,7 @@ export function ToolCalls({ type="button" variant="ghost" size="sm" - className="h-auto bg-transparent px-0 py-0 font-normal hover:bg-transparent" + className="h-auto bg-transparent px-0 py-0 font-normal hover:bg-transparent dark:hover:bg-transparent" onClick={() => setIsToolCallsOpen(!isToolCallsOpen)} >

Tool Calls

@@ -1250,7 +1250,7 @@ export function ToolCalls({ )} />

-

+

{JSON.stringify(toolCall.arguments, null, 2)} @@ -1273,14 +1273,14 @@ export function ToolCalls({ )} {toolCall.status === 'completed' && ( -

+

{JSON.stringify(toolCall.result, null, 2)}

)} {toolCall.status === 'error' && ( -

+

{toolCall.error} diff --git a/frontend/src/conversation/ConversationMessages.tsx b/frontend/src/conversation/ConversationMessages.tsx index 019ad0e6..f4872903 100644 --- a/frontend/src/conversation/ConversationMessages.tsx +++ b/frontend/src/conversation/ConversationMessages.tsx @@ -1,26 +1,21 @@ -import { - Fragment, - ReactNode, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; +import { Fragment, ReactNode, useEffect, useLayoutEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import ArrowDown from '../assets/arrow-down.svg'; import DocsGPT3 from '../assets/cute_docsgpt3.svg'; import Retry from '../assets/retry.svg?react'; import { Button } from '../components/ui/button'; +import { + MessageScroller, + MessageScrollerButton, + MessageScrollerContent, + MessageScrollerItem, + MessageScrollerProvider, + MessageScrollerViewport, +} from '../components/ui/message-scroller'; import Hero from '../Hero'; import ConversationBubble from './ConversationBubble'; import { FEEDBACK, Query, Status } from './conversationModels'; -const SCROLL_THRESHOLD = 10; -const LAST_BUBBLE_MARGIN = 'mb-32'; -const DEFAULT_BUBBLE_MARGIN = 'mb-7'; -const FIRST_QUESTION_BUBBLE_MARGIN_TOP = 'mt-5'; - type ConversationMessagesProps = { handleQuestion: (params: { question: string; @@ -48,6 +43,13 @@ type ConversationMessagesProps = { agentId?: string; }; +const MS_VIEWPORT_SELECTOR = '[data-slot="message-scroller-viewport"]'; +const STICK_THRESHOLD_PX = 48; + +const LAST_BUBBLE_MARGIN = 'mb-32'; +const DEFAULT_BUBBLE_MARGIN = 'mb-7'; +const FIRST_QUESTION_BUBBLE_MARGIN_TOP = 'mt-5'; + export default function ConversationMessages({ handleQuestion, handleQuestionSubmission, @@ -63,178 +65,35 @@ export default function ConversationMessages({ }: ConversationMessagesProps) { const { t } = useTranslation(); - const conversationRef = useRef(null); - 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); - - useEffect(() => { - return () => { - clearTimeout(smoothScrollTimeoutRef.current); - clearTimeout(showButtonTimerRef.current); - }; - }, []); - - const isAtBottom = useCallback(() => { - const el = conversationRef.current; - if (!el) return true; - return el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD; - }, []); - - // 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]); - - 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(); - } - lastTouchYRef.current = y; - }, - [markInterruptedIfLoading], - ); - - 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 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 hasMessages = queries.length > 0; const lastQuery = queries[queries.length - 1]; - const lastQueryResponse = lastQuery?.response; - const lastQueryError = lastQuery?.error; - const lastQueryThought = lastQuery?.thought; - + const lastTurnContentLength = + (lastQuery?.thought?.length ?? 0) + (lastQuery?.response?.length ?? 0); + const stickToBottomRef = useRef(true); useEffect(() => { - 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, - interrupted, - scrollConversationToBottom, - ]); - - useEffect(() => { - if (status === 'idle') { - userInterruptedRef.current = false; - setInterrupted(false); - } - }, [status]); - - useEffect(() => { - const currentConversationRef = conversationRef.current; - currentConversationRef?.addEventListener('scroll', handleScroll); - return () => { - currentConversationRef?.removeEventListener('scroll', handleScroll); + if (!hasMessages) return; + const vp = document.querySelector(MS_VIEWPORT_SELECTOR); + if (!vp) return; + const onScroll = () => { + stickToBottomRef.current = + vp.scrollHeight - vp.scrollTop - vp.clientHeight < STICK_THRESHOLD_PX; }; - }, [handleScroll]); + onScroll(); + vp.addEventListener('scroll', onScroll, { passive: true }); + return () => vp.removeEventListener('scroll', onScroll); + }, [hasMessages]); + + useLayoutEffect(() => { + if (status !== 'loading' || !hasMessages) return; + const vp = document.querySelector(MS_VIEWPORT_SELECTOR); + if (!vp) return; + const dist = vp.scrollHeight - vp.scrollTop - vp.clientHeight; + if (stickToBottomRef.current && dist > 0) vp.scrollTop = vp.scrollHeight; + }, [status, lastTurnContentLength, hasMessages]); + + const columnClass = isSplitView + ? 'w-full max-w-325 px-2' + : 'w-full max-w-325 px-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12'; const renderResponseView = (query: Query, index: number) => { const isLastMessage = index === queries.length - 1; @@ -328,10 +187,10 @@ export default function ConversationMessages({ ); } - if (status === 'loading' && isLastMessage) { + if (status === 'loading' && index === queries.length - 1) { return (

@@ -361,68 +220,52 @@ export default function ConversationMessages({ return null; }; - return ( -
- {queries.length > 0 && ( - - )} - -
- {headerContent} - - {queries.length > 0 ? ( - queries.map((query, index) => ( - - - {renderResponseView(query, index)} - - )) - ) : showHeroOnEmpty ? ( - - ) : null} + if (queries.length === 0) { + return ( +
+
+ {headerContent} + {showHeroOnEmpty ? : null} +
-
+ ); + } + + return ( + + + + + {headerContent} + {queries.map((query, index) => { + const responseView = renderResponseView(query, index); + return ( + + + + + {responseView && ( + + {responseView} + + )} + + ); + })} + + + + + ); }