(feat)message scroller - no explicit space, workflow prev uses message scroller

This commit is contained in:
ManishMadan2882 committed 2026-07-16 19:01:30 +05:30
1 parent d78ddeadd0
commit 563ead105a
2 files changed
+168 -100

No files matched your search

+124 -92
View File
@@ -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<Map<string, HTMLDivElement>>(new Map());
const chatContainerRef = useRef<HTMLDivElement>(null);
const scrollToStep = useCallback(
(nodeId: string) => {
@@ -638,13 +645,10 @@ export default function WorkflowPreview({
</div>
</div>
<div className="relative flex min-w-0 flex-1 flex-col">
<div
ref={chatContainerRef}
className="absolute inset-0 bottom-[100px] scrollbar-thin overflow-y-auto px-4 pt-4"
>
<div className="flex min-w-0 flex-1 flex-col">
<div className="relative min-h-0 flex-1">
{queries.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center">
<div className="flex h-full flex-col items-center justify-center px-4">
<div className="bg-muted mb-2 flex size-14 shrink-0 items-center justify-center rounded-xl">
<MessageSquare className="size-6 text-gray-600 dark:text-gray-300" />
</div>
@@ -653,96 +657,124 @@ export default function WorkflowPreview({
</p>
</div>
) : (
<div className="w-full">
{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);
<MessageScrollerProvider autoScroll>
<MessageScroller>
<MessageScrollerViewport className="scrollbar-thin px-4 pt-4">
<MessageScrollerContent className="w-full">
{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 (
<div key={index}>
{/* Query bubble */}
<ConversationBubble
className={index === 0 ? 'mt-5' : ''}
message={query.prompt}
type="QUESTION"
handleUpdatedQuestionSubmission={
handleQuestionSubmission
}
questionNumber={index}
/>
return (
<Fragment key={index}>
<MessageScrollerItem
messageId={`q-${index}`}
scrollAnchor
>
<ConversationBubble
className={index === 0 ? 'mt-5' : ''}
message={query.prompt}
type="QUESTION"
handleUpdatedQuestionSubmission={
handleQuestionSubmission
}
questionNumber={index}
/>
</MessageScrollerItem>
{/* Execution Details */}
{querySteps.length > 0 && (
<ExecutionDetails
steps={querySteps}
nodes={workflowData.nodes}
isOpen={isOpen}
onToggle={() =>
setOpenDetailsIndex(
openDetailsIndex === index ? null : index,
)
}
stepRefs={isLastQuery ? stepRefs : undefined}
/>
)}
{hasResponseItem && (
<MessageScrollerItem messageId={`a-${index}`}>
{/* Execution Details */}
{querySteps.length > 0 && (
<ExecutionDetails
steps={querySteps}
nodes={workflowData.nodes}
isOpen={isOpen}
onToggle={() =>
setOpenDetailsIndex(
openDetailsIndex === index
? null
: index,
)
}
stepRefs={
isLastQuery ? stepRefs : undefined
}
/>
)}
{query.workflowRunId && (
<RunArtifactsSection
workflowRunId={query.workflowRunId}
isOpen={openArtifactsIndex === index}
onToggle={() =>
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 && (
<RunArtifactsSection
workflowRunId={query.workflowRunId}
isOpen={openArtifactsIndex === index}
onToggle={() =>
setOpenArtifactsIndex(
openArtifactsIndex === index ? null : index,
)
}
runInProgress={isStreamingLastQuery}
/>
)}
{/* Response bubble */}
{hasAnswerBubble && (
<ConversationBubble
className="mb-7"
message={query.response}
type="ANSWER"
thought={
shouldShowThought
? query.thought
: undefined
}
sources={query.sources}
toolCalls={query.tool_calls}
feedback={query.feedback}
isStreaming={isStreamingLastQuery}
/>
)}
{/* Response bubble */}
{(query.response ||
shouldShowThought ||
query.tool_calls) && (
<ConversationBubble
className={isLastQuery ? 'mb-32' : 'mb-7'}
message={query.response}
type="ANSWER"
thought={
shouldShowThought ? query.thought : undefined
}
sources={query.sources}
toolCalls={query.tool_calls}
feedback={query.feedback}
isStreaming={isStreamingLastQuery}
/>
)}
{/* Error bubble */}
{query.error && (
<ConversationBubble
className={isLastQuery ? 'mb-32' : 'mb-7'}
message={query.error}
type="ERROR"
/>
)}
</div>
);
})}
</div>
{/* Error bubble */}
{query.error && (
<ConversationBubble
className="mb-7"
message={query.error}
type="ERROR"
/>
)}
</MessageScrollerItem>
)}
</Fragment>
);
})}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
)}
</div>
<div className="bg-card absolute right-0 bottom-0 left-0 flex w-full flex-col gap-2 px-4 pt-2 pb-4">
<div className="bg-card flex w-full flex-col gap-2 px-4 pt-2 pb-4">
{sendBlockedMessage && (
<p className="text-xs text-red-500" role="alert">
{sendBlockedMessage}
@@ -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<HTMLElement>(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<HTMLElement>('.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({
<MessageScrollerProvider autoScroll>
<MessageScroller>
<MessageScrollerViewport className="sm:pt-6 lg:pt-12">
<MessageScrollerContent className={`mx-auto pb-7 ${columnClass}`}>
<MessageScrollerContent
spacerClassName={
spacerCollapsed ? 'msc-spacer max-h-0' : 'msc-spacer'
}
className={`mx-auto pb-7 ${columnClass}`}
>
{headerContent}
{queries.map((query, index) => {
const responseView = renderResponseView(query, index);