import { Fragment, ReactNode, useCallback, useEffect, useRef, useState, } from 'react'; import { useTranslation } from 'react-i18next'; import ArrowDown from '../assets/arrow-down.svg'; import RetryIcon from '../components/RetryIcon'; import Hero from '../Hero'; import { useDarkTheme } from '../hooks'; 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; isRetry?: boolean; updated?: boolean | null; indx?: number; }) => void; handleQuestionSubmission: ( updatedQuestion?: string, updated?: boolean, indx?: number, ) => void; handleFeedback?: (query: Query, feedback: FEEDBACK, index: number) => void; queries: Query[]; status: Status; showHeroOnEmpty?: boolean; headerContent?: ReactNode; }; export default function ConversationMessages({ handleQuestion, handleQuestionSubmission, queries, status, handleFeedback, showHeroOnEmpty = true, headerContent, }: ConversationMessagesProps) { const [isDarkTheme] = useDarkTheme(); const { t } = useTranslation(); const conversationRef = useRef(null); const [hasScrolledToLast, setHasScrolledToLast] = useState(true); const [userInterruptedScroll, setUserInterruptedScroll] = useState(false); const handleUserScrollInterruption = useCallback(() => { if (!userInterruptedScroll && status === 'loading') { setUserInterruptedScroll(true); } }, [userInterruptedScroll, status]); const scrollConversationToBottom = useCallback(() => { if (!conversationRef.current || userInterruptedScroll) return; requestAnimationFrame(() => { if (!conversationRef?.current) return; if (status === 'idle' || !queries[queries.length - 1]?.response) { conversationRef.current.scrollTo({ behavior: 'smooth', top: conversationRef.current.scrollHeight, }); } else { conversationRef.current.scrollTop = conversationRef.current.scrollHeight; } }); }, [userInterruptedScroll, status, queries]); const checkScrollPosition = useCallback(() => { const el = conversationRef.current; if (!el) return; const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD; setHasScrolledToLast(isAtBottom); }, [setHasScrolledToLast]); useEffect(() => { 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') { setUserInterruptedScroll(false); } }, [status]); useEffect(() => { const currentConversationRef = conversationRef.current; currentConversationRef?.addEventListener('scroll', checkScrollPosition); return () => { 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; if (query.thought || query.response) { const isCurrentlyStreaming = status === 'loading' && index === queries.length - 1; return ( handleFeedback(query, feedback, index) : undefined } /> ); } if (query.error) { const retryButton = ( ); return ( ); } return null; }; return (
{queries.length > 0 && !hasScrolledToLast && ( )}
{headerContent && headerContent} {queries.length > 0 ? ( queries.map((query, index) => ( {renderResponseView(query, index)} )) ) : showHeroOnEmpty ? ( ) : null}
); }