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);