mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 22:15:10 +00:00
(feat)message scroller - no explicit space, workflow prev uses message scroller
This commit is contained in:
1 parent
d78ddeadd0
commit
563ead105a
2 files changed
+168
-100
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user