From d78ddeadd0a37ed799d0c17171d3ccdad807b509 Mon Sep 17 00:00:00 2001 From: ManishMadan2882 Date: Sun, 12 Jul 2026 20:27:36 +0530 Subject: [PATCH 1/2] (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} + + )} + + ); + })} + + + + + ); } From 563ead105a0b86c612afa18107bbd5c1e3e95444 Mon Sep 17 00:00:00 2001 From: ManishMadan2882 Date: Thu, 16 Jul 2026 19:01:30 +0530 Subject: [PATCH 2/2] (feat)message scroller - no explicit space, workflow prev uses message scroller --- .../src/agents/workflow/WorkflowPreview.tsx | 216 ++++++++++-------- .../src/conversation/ConversationMessages.tsx | 52 ++++- 2 files changed, 168 insertions(+), 100 deletions(-) diff --git a/frontend/src/agents/workflow/WorkflowPreview.tsx b/frontend/src/agents/workflow/WorkflowPreview.tsx index 8f3b51a8..b400ceb6 100644 --- a/frontend/src/agents/workflow/WorkflowPreview.tsx +++ b/frontend/src/agents/workflow/WorkflowPreview.tsx @@ -14,10 +14,18 @@ import { Workflow, XCircle, } from 'lucide-react'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Button } from '@/components/ui/button'; +import { + MessageScroller, + MessageScrollerButton, + MessageScrollerContent, + MessageScrollerItem, + MessageScrollerProvider, + MessageScrollerViewport, +} from '@/components/ui/message-scroller'; import { cn } from '@/lib/utils'; import ChevronDownIcon from '../../assets/chevron-down.svg'; @@ -461,7 +469,6 @@ export default function WorkflowPreview({ const fetchStream = useRef<{ abort: () => void } | null>(null); const stepRefs = useRef>(new Map()); - const chatContainerRef = useRef(null); const scrollToStep = useCallback( (nodeId: string) => { @@ -638,13 +645,10 @@ export default function WorkflowPreview({
-
-
+
+
{queries.length === 0 ? ( -
+
@@ -653,96 +657,124 @@ export default function WorkflowPreview({

) : ( -
- {queries.map((query, index) => { - const querySteps = query.executionSteps || []; - const hasResponse = !!(query.response || query.error); - const isLastQuery = index === queries.length - 1; - const isStreamingLastQuery = - status === 'loading' && isLastQuery; - const shouldShowThought = - !isStreamingLastQuery && Boolean(query.thought); - const isOpen = - openDetailsIndex === index || - (!hasResponse && isLastQuery && querySteps.length > 0); + + + + + {queries.map((query, index) => { + const querySteps = query.executionSteps || []; + const hasResponse = !!(query.response || query.error); + const isLastQuery = index === queries.length - 1; + const isStreamingLastQuery = + status === 'loading' && isLastQuery; + const shouldShowThought = + !isStreamingLastQuery && Boolean(query.thought); + const isOpen = + openDetailsIndex === index || + (!hasResponse && + isLastQuery && + querySteps.length > 0); + const hasAnswerBubble = + !!query.response || + shouldShowThought || + !!query.tool_calls; + const hasResponseItem = + querySteps.length > 0 || + !!query.workflowRunId || + hasAnswerBubble || + !!query.error; - return ( -
- {/* Query bubble */} - + return ( + + + + - {/* Execution Details */} - {querySteps.length > 0 && ( - - setOpenDetailsIndex( - openDetailsIndex === index ? null : index, - ) - } - stepRefs={isLastQuery ? stepRefs : undefined} - /> - )} + {hasResponseItem && ( + + {/* Execution Details */} + {querySteps.length > 0 && ( + + setOpenDetailsIndex( + openDetailsIndex === index + ? null + : index, + ) + } + stepRefs={ + isLastQuery ? stepRefs : undefined + } + /> + )} + {query.workflowRunId && ( + + setOpenArtifactsIndex( + openArtifactsIndex === index + ? null + : index, + ) + } + runInProgress={isStreamingLastQuery} + /> + )} - {/* Run artifacts — shown as soon as a persisted run id - is known; mid-run it lists artifacts as nodes produce - them and refetches when the run completes. */} - {query.workflowRunId && ( - - setOpenArtifactsIndex( - openArtifactsIndex === index ? null : index, - ) - } - runInProgress={isStreamingLastQuery} - /> - )} + {/* Response bubble */} + {hasAnswerBubble && ( + + )} - {/* Response bubble */} - {(query.response || - shouldShowThought || - query.tool_calls) && ( - - )} - - {/* Error bubble */} - {query.error && ( - - )} -
- ); - })} -
+ {/* Error bubble */} + {query.error && ( + + )} + + )} + + ); + })} + + + + + )}
-
+
{sendBlockedMessage && (

{sendBlockedMessage} diff --git a/frontend/src/conversation/ConversationMessages.tsx b/frontend/src/conversation/ConversationMessages.tsx index f4872903..6fe2b6e6 100644 --- a/frontend/src/conversation/ConversationMessages.tsx +++ b/frontend/src/conversation/ConversationMessages.tsx @@ -1,4 +1,11 @@ -import { Fragment, ReactNode, useEffect, useLayoutEffect, useRef } from 'react'; +import { + Fragment, + ReactNode, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { useTranslation } from 'react-i18next'; import DocsGPT3 from '../assets/cute_docsgpt3.svg'; @@ -46,7 +53,6 @@ type ConversationMessagesProps = { 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'; @@ -70,13 +76,40 @@ export default function ConversationMessages({ const lastTurnContentLength = (lastQuery?.thought?.length ?? 0) + (lastQuery?.response?.length ?? 0); const stickToBottomRef = useRef(true); + + // pin-to-top jump + // once the user scrolls up past the pin's reserved spacer, collapse it (via the scroller's + // own spacerClassName) so it never lingers as dead space. Re-arms on the next + // turn so the following question can pin to the top again. + const [spacerCollapsed, setSpacerCollapsed] = useState(false); + const spacerCollapsedRef = useRef(false); + const prevQueryCountRef = useRef(queries.length); + useEffect(() => { + if (queries.length > prevQueryCountRef.current) { + spacerCollapsedRef.current = false; + setSpacerCollapsed(false); + } + prevQueryCountRef.current = queries.length; + }, [queries.length]); + useEffect(() => { 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; + const dist = vp.scrollHeight - vp.scrollTop - vp.clientHeight; + stickToBottomRef.current = dist < STICK_THRESHOLD_PX; + // Collapse only once the spacer has scrolled fully below the fold + // (dist >= its height) so removing it never shifts the visible content. + // Reading style.height (the primitive's inline value) avoids a reflow. + if (!spacerCollapsedRef.current) { + const spacer = vp.querySelector('.msc-spacer'); + const spacerH = spacer ? parseFloat(spacer.style.height) || 0 : 0; + if (spacerH > STICK_THRESHOLD_PX && dist >= spacerH) { + spacerCollapsedRef.current = true; + setSpacerCollapsed(true); + } + } }; onScroll(); vp.addEventListener('scroll', onScroll, { passive: true }); @@ -97,9 +130,7 @@ export default function ConversationMessages({ const renderResponseView = (query: Query, index: number) => { const isLastMessage = index === queries.length - 1; - const bubbleMargin = isLastMessage - ? LAST_BUBBLE_MARGIN - : DEFAULT_BUBBLE_MARGIN; + const bubbleMargin = DEFAULT_BUBBLE_MARGIN; // Error first; reconciler-failed rows may carry partial thought/ // tool_calls and would otherwise fall into the answer branch. @@ -235,7 +266,12 @@ export default function ConversationMessages({ - + {headerContent} {queries.map((query, index) => { const responseView = renderResponseView(query, index);