This commit is contained in:
ManishMadan2882
2025-05-16 04:12:27 +05:30
27 changed files with 1435 additions and 296 deletions

View File

@@ -1,4 +1,11 @@
import { Fragment, useEffect, useRef, useState } from 'react';
import {
Fragment,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import ArrowDown from '../assets/arrow-down.svg';
@@ -8,7 +15,12 @@ import { useDarkTheme } from '../hooks';
import ConversationBubble from './ConversationBubble';
import { FEEDBACK, Query, Status } from './conversationModels';
interface ConversationMessagesProps {
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;
isRetry?: boolean;
@@ -24,7 +36,8 @@ interface ConversationMessagesProps {
queries: Query[];
status: Status;
showHeroOnEmpty?: boolean;
}
headerContent?: ReactNode;
};
export default function ConversationMessages({
handleQuestion,
@@ -33,22 +46,23 @@ export default function ConversationMessages({
status,
handleFeedback,
showHeroOnEmpty = true,
headerContent,
}: ConversationMessagesProps) {
const [isDarkTheme] = useDarkTheme();
const { t } = useTranslation();
const conversationRef = useRef<HTMLDivElement>(null);
const [atLast, setAtLast] = useState(true);
const [eventInterrupt, setEventInterrupt] = useState(false);
const [hasScrolledToLast, setHasScrolledToLast] = useState(true);
const [userInterruptedScroll, setUserInterruptedScroll] = useState(false);
const handleUserInterruption = () => {
if (!eventInterrupt && status === 'loading') {
setEventInterrupt(true);
const handleUserScrollInterruption = useCallback(() => {
if (!userInterruptedScroll && status === 'loading') {
setUserInterruptedScroll(true);
}
};
}, [userInterruptedScroll, status]);
const scrollIntoView = () => {
if (!conversationRef?.current || eventInterrupt) return;
const scrollConversationToBottom = useCallback(() => {
if (!conversationRef.current || userInterruptedScroll) return;
requestAnimationFrame(() => {
if (!conversationRef?.current) return;
@@ -63,41 +77,67 @@ export default function ConversationMessages({
conversationRef.current.scrollHeight;
}
});
};
}, [userInterruptedScroll, status, queries]);
const checkScroll = () => {
const checkScrollPosition = useCallback(() => {
const el = conversationRef.current;
if (!el) return;
const isBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 10;
setAtLast(isBottom);
};
const isAtBottom =
el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD;
setHasScrolledToLast(isAtBottom);
}, [setHasScrolledToLast]);
useEffect(() => {
!eventInterrupt && scrollIntoView();
}, [queries.length, queries[queries.length - 1]]);
if (!userInterruptedScroll) {
scrollConversationToBottom();
}
}, [
queries.length,
queries[queries.length - 1]?.response,
queries[queries.length - 1]?.error,
queries[queries.length - 1]?.thought,
userInterruptedScroll,
scrollConversationToBottom,
]);
useEffect(() => {
if (status === 'idle') {
setEventInterrupt(false);
setUserInterruptedScroll(false);
}
}, [status]);
useEffect(() => {
conversationRef.current?.addEventListener('scroll', checkScroll);
const currentConversationRef = conversationRef.current;
currentConversationRef?.addEventListener('scroll', checkScrollPosition);
return () => {
conversationRef.current?.removeEventListener('scroll', checkScroll);
currentConversationRef?.removeEventListener(
'scroll',
checkScrollPosition,
);
};
}, []);
}, [checkScrollPosition]);
const retryIconProps = {
width: 12,
height: 12,
fill: isDarkTheme ? 'rgb(236 236 241)' : 'rgb(107 114 120)',
stroke: isDarkTheme ? 'rgb(236 236 241)' : 'rgb(107 114 120)',
strokeWidth: 10,
};
const renderResponseView = (query: Query, index: number) => {
const isLastMessage = index === queries.length - 1;
const bubbleMargin = isLastMessage
? LAST_BUBBLE_MARGIN
: DEFAULT_BUBBLE_MARGIN;
const prepResponseView = (query: Query, index: number) => {
let responseView;
if (query.thought || query.response) {
const isCurrentlyStreaming =
status === 'loading' && index === queries.length - 1;
responseView = (
return (
<ConversationBubble
className={`${index === queries.length - 1 ? 'mb-32' : 'mb-7'}`}
key={`${index}ANSWER`}
className={bubbleMargin}
key={`${index}-ANSWER`}
message={query.response}
type={'ANSWER'}
thought={query.thought}
@@ -112,51 +152,53 @@ export default function ConversationMessages({
}
/>
);
} else if (query.error) {
const retryBtn = (
}
if (query.error) {
const retryButton = (
<button
className="flex items-center justify-center gap-3 self-center rounded-full px-5 py-3 text-lg text-gray-500 transition-colors delay-100 hover:border-gray-500 disabled:cursor-not-allowed dark:text-bright-gray"
disabled={status === 'loading'}
onClick={() => {
const questionToRetry = queries[index].prompt;
handleQuestion({
question: queries[queries.length - 1].prompt,
question: questionToRetry,
isRetry: true,
indx: index,
});
}}
aria-label={t('Retry') || 'Retry'}
>
<RetryIcon
width={12}
height={12}
fill={isDarkTheme ? 'rgb(236 236 241)' : 'rgb(107 114 120)'}
stroke={isDarkTheme ? 'rgb(236 236 241)' : 'rgb(107 114 120)'}
strokeWidth={10}
/>
<RetryIcon {...retryIconProps} />
</button>
);
responseView = (
return (
<ConversationBubble
className={`${index === queries.length - 1 ? 'mb-32' : 'mb-7'} `}
key={`${index}ERROR`}
className={bubbleMargin}
key={`${index}-ERROR`}
message={query.error}
type="ERROR"
retryBtn={retryBtn}
retryBtn={retryButton}
/>
);
}
return responseView;
return null;
};
return (
<div
ref={conversationRef}
onWheel={handleUserInterruption}
onTouchMove={handleUserInterruption}
onWheel={handleUserScrollInterruption}
onTouchMove={handleUserScrollInterruption}
className="flex h-full w-full justify-center overflow-y-auto will-change-scroll sm:pt-6 lg:pt-12"
>
{queries.length > 0 && !atLast && (
{queries.length > 0 && !hasScrolledToLast && (
<button
onClick={scrollIntoView}
aria-label="scroll to bottom"
onClick={() => {
setUserInterruptedScroll(false);
scrollConversationToBottom();
}}
aria-label={t('Scroll to bottom') || 'Scroll to bottom'}
className="fixed bottom-40 right-14 z-10 flex h-7 w-7 items-center justify-center rounded-full border-[0.5px] border-gray-alpha bg-gray-100 bg-opacity-50 dark:bg-gunmetal md:h-9 md:w-9 md:bg-opacity-100"
>
<img
@@ -168,19 +210,21 @@ export default function ConversationMessages({
)}
<div className="w-full max-w-[1300px] px-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
{headerContent && headerContent}
{queries.length > 0 ? (
queries.map((query, index) => (
<Fragment key={index}>
<Fragment key={`${index}-query-fragment`}>
<ConversationBubble
className={'first:mt-5'}
key={`${index}QUESTION`}
className={index === 0 ? FIRST_QUESTION_BUBBLE_MARGIN_TOP : ''}
key={`${index}-QUESTION`}
message={query.prompt}
type="QUESTION"
handleUpdatedQuestionSubmission={handleQuestionSubmission}
questionNumber={index}
sources={query.sources}
/>
{prepResponseView(query, index)}
{renderResponseView(query, index)}
</Fragment>
))
) : showHeroOnEmpty ? (