Merge pull request #2591 from ManishMadan2882/feat/scroller-migration

(feat)chats: shadcn msg scroller, fix jitter
This commit is contained in:
Pavel authored and GitHub committed 2026-07-16 19:44:52 +03:00
commit 1fc43d5b9b
7 files changed
+418 -336

No files matched your search

+19
View File
@@ -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",
+1
View File
@@ -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",
+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}
@@ -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<typeof MessageScrollerPrimitive.Provider>,
) {
return <MessageScrollerPrimitive.Provider {...props} />;
}
function MessageScroller({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) {
return (
<MessageScrollerPrimitive.Root
data-slot="message-scroller"
className={cn(
'group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden',
className,
)}
{...props}
/>
);
}
function MessageScrollerViewport({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) {
return (
<MessageScrollerPrimitive.Viewport
data-slot="message-scroller-viewport"
className={cn(
// Registry default also has: scroll-fade-b scrollbar-thin
// scrollbar-gutter-stable data-autoscrolling:scrollbar-none.
// scrollbar-thin is a real @utility here (index.css) and would thin
// the chat scrollbar; the fade would double Conversation's gradient.
'size-full min-h-0 min-w-0 overflow-y-auto overscroll-contain contain-content',
className,
)}
{...props}
/>
);
}
function MessageScrollerContent({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {
return (
<MessageScrollerPrimitive.Content
data-slot="message-scroller-content"
className={cn('flex h-max min-h-full flex-col', className)}
{...props}
/>
);
}
function MessageScrollerItem({
className,
scrollAnchor = false,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) {
return (
<MessageScrollerPrimitive.Item
data-slot="message-scroller-item"
scrollAnchor={scrollAnchor}
className={cn(
// Registry default adds [content-visibility:auto]
// [contain-intrinsic-size:auto_10rem] here as an off-screen render
// optimization. Dropped: content-visibility:auto forces paint
// containment, which clips hover popovers that escape a message box
// (e.g. the source-preview tooltip) at the message's right edge.
'min-w-0 shrink-0',
className,
)}
{...props}
/>
);
}
function MessageScrollerButton({
direction = 'end',
className,
children,
render,
variant = 'secondary',
size = 'icon-sm',
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) {
return (
<MessageScrollerPrimitive.Button
data-slot="message-scroller-button"
data-direction={direction}
data-variant={variant}
data-size={size}
direction={direction}
className={cn(
'border-border bg-background text-foreground hover:bg-muted hover:text-foreground absolute inset-s-1/2 -translate-x-1/2 transition-[translate,scale,opacity] duration-200 data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180',
className,
)}
render={render ?? <Button variant={variant} size={size} />}
{...props}
>
{children ?? (
<>
<ArrowDownIcon />
<span className="sr-only">
{direction === 'end' ? 'Scroll to end' : 'Scroll to start'}
</span>
</>
)}
</MessageScrollerPrimitive.Button>
);
}
export {
MessageScrollerProvider,
MessageScroller,
MessageScrollerViewport,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerButton,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
};
+15 -1
View File
@@ -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() {
>
<div className="relative min-h-0 flex-1">
<ConversationMessages
key={conversationMountKey}
handleQuestion={handleQuestion}
handleQuestionSubmission={handleQuestionSubmission}
handleFeedback={handleFeedback}
@@ -372,7 +386,7 @@ export default function Conversation() {
>
<div className="flex w-full items-center rounded-full px-2">
<MessageInput
key={conversationId || 'new'}
key={conversationMountKey}
onSubmit={(text) => {
handleQuestionSubmission(text);
}}
@@ -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)}
>
<p className="text-base font-semibold">Tool Calls</p>
@@ -1250,7 +1250,7 @@ export function ToolCalls({
)}
/>
</p>
<p className="dark:bg-card rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<p className="dark:bg-card max-h-80 overflow-y-auto rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<span className="dark:text-muted-foreground leading-5.75 text-black">
{JSON.stringify(toolCall.arguments, null, 2)}
</span>
@@ -1273,14 +1273,14 @@ export function ToolCalls({
</span>
)}
{toolCall.status === 'completed' && (
<p className="dark:bg-card rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<p className="dark:bg-card max-h-80 overflow-y-auto rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<span className="dark:text-muted-foreground leading-5.75 text-black">
{JSON.stringify(toolCall.result, null, 2)}
</span>
</p>
)}
{toolCall.status === 'error' && (
<p className="dark:bg-card rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<p className="dark:bg-card max-h-80 overflow-y-auto rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<span className="text-destructive leading-5.75">
{toolCall.error}
</span>
+118 -239
View File
@@ -1,26 +1,28 @@
import {
Fragment,
ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} 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 +50,12 @@ type ConversationMessagesProps = {
agentId?: string;
};
const MS_VIEWPORT_SELECTOR = '[data-slot="message-scroller-viewport"]';
const STICK_THRESHOLD_PX = 48;
const DEFAULT_BUBBLE_MARGIN = 'mb-7';
const FIRST_QUESTION_BUBBLE_MARGIN_TOP = 'mt-5';
export default function ConversationMessages({
handleQuestion,
handleQuestionSubmission,
@@ -63,184 +71,66 @@ export default function ConversationMessages({
}: ConversationMessagesProps) {
const { t } = useTranslation();
const conversationRef = useRef<HTMLDivElement>(null);
const [scrollButtonVisible, setScrollButtonVisible] = useState(false);
const userInterruptedRef = useRef(false);
const [interrupted, setInterrupted] = useState(false);
const lastTouchYRef = useRef<number | null>(null);
const isInitialLoad = useRef(true);
const prevQueriesRef = useRef(queries);
const isAutoScrollingRef = useRef(false);
const smoothScrollTimeoutRef =
useRef<ReturnType<typeof setTimeout>>(undefined);
const showButtonTimerRef = useRef<ReturnType<typeof setTimeout>>(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);
// 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 (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;
if (queries.length > prevQueryCountRef.current) {
spacerCollapsedRef.current = false;
setSpacerCollapsed(false);
}
const isNewMessage = queries.length > prevQueries.length;
prevQueriesRef.current = queries;
scrollConversationToBottom(isNewMessage ? false : true);
}, [
queries.length,
lastQueryResponse,
lastQueryError,
lastQueryThought,
interrupted,
scrollConversationToBottom,
]);
prevQueryCountRef.current = queries.length;
}, [queries.length]);
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<HTMLElement>(MS_VIEWPORT_SELECTOR);
if (!vp) return;
const onScroll = () => {
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);
}
}
};
}, [handleScroll]);
onScroll();
vp.addEventListener('scroll', onScroll, { passive: true });
return () => vp.removeEventListener('scroll', onScroll);
}, [hasMessages]);
useLayoutEffect(() => {
if (status !== 'loading' || !hasMessages) return;
const vp = document.querySelector<HTMLElement>(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;
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.
@@ -328,10 +218,10 @@ export default function ConversationMessages({
);
}
if (status === 'loading' && isLastMessage) {
if (status === 'loading' && index === queries.length - 1) {
return (
<div
className={`fade-in-bubble flex flex-wrap self-start ${bubbleMargin} group dark:text-foreground flex-col`}
className={`fade-in-bubble group dark:text-foreground flex flex-col flex-wrap self-start ${bubbleMargin}`}
>
<div className="flex max-w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
@@ -361,68 +251,57 @@ export default function ConversationMessages({
return null;
};
return (
<div
ref={conversationRef}
onWheel={handleWheel}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
className="flex h-full w-full justify-center overflow-y-auto will-change-scroll sm:pt-6 lg:pt-12"
>
{queries.length > 0 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
userInterruptedRef.current = false;
setInterrupted(false);
scrollConversationToBottom();
}}
aria-label={t('Scroll to bottom') || 'Scroll to bottom'}
className={`bg-card fixed bottom-40 left-1/2 z-10 h-7 w-7 -translate-x-1/2 rounded-full transition-all duration-300 ease-in-out md:right-14 md:left-auto md:h-9 md:w-9 md:translate-x-0 ${
scrollButtonVisible
? 'pointer-events-auto scale-100 opacity-100'
: 'pointer-events-none scale-75 opacity-0'
}`}
>
<img
src={ArrowDown}
alt="arrow down"
className="h-4 w-4 opacity-50 filter md:h-5 md:w-5 dark:invert"
/>
</Button>
)}
<div
className={
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'
}
>
{headerContent}
{queries.length > 0 ? (
queries.map((query, index) => (
<Fragment key={`${index}-query-fragment`}>
<ConversationBubble
className={index === 0 ? FIRST_QUESTION_BUBBLE_MARGIN_TOP : ''}
key={`${index}-QUESTION`}
message={query.prompt}
type="QUESTION"
handleUpdatedQuestionSubmission={handleQuestionSubmission}
questionNumber={index}
sources={query.sources}
filesAttached={query.attachments}
/>
{renderResponseView(query, index)}
</Fragment>
))
) : showHeroOnEmpty ? (
<Hero handleQuestion={handleQuestion} />
) : null}
if (queries.length === 0) {
return (
<div className="flex h-full w-full justify-center overflow-y-auto will-change-scroll sm:pt-6 lg:pt-12">
<div className={columnClass}>
{headerContent}
{showHeroOnEmpty ? <Hero handleQuestion={handleQuestion} /> : null}
</div>
</div>
</div>
);
}
return (
<MessageScrollerProvider autoScroll>
<MessageScroller>
<MessageScrollerViewport className="sm:pt-6 lg:pt-12">
<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);
return (
<Fragment key={`${index}-query-fragment`}>
<MessageScrollerItem messageId={`q-${index}`} scrollAnchor>
<ConversationBubble
className={
index === 0 ? FIRST_QUESTION_BUBBLE_MARGIN_TOP : ''
}
message={query.prompt}
type="QUESTION"
handleUpdatedQuestionSubmission={handleQuestionSubmission}
questionNumber={index}
sources={query.sources}
filesAttached={query.attachments}
/>
</MessageScrollerItem>
{responseView && (
<MessageScrollerItem messageId={`a-${index}`}>
{responseView}
</MessageScrollerItem>
)}
</Fragment>
);
})}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
);
}