mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 22:14:05 +00:00
(feat)chats: shadcn msg scroller, fix jitter
This commit is contained in:
1 parent
3e978759de
commit
d78ddeadd0
6 files changed
+266
-252
No files matched your search
Generated
+19
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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<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);
|
||||
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<HTMLElement>(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<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;
|
||||
@@ -328,10 +187,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 +220,52 @@ 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 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user