diff --git a/src/interface/web/app/components/chatHistory/chatHistory.tsx b/src/interface/web/app/components/chatHistory/chatHistory.tsx index df41a426..46b0bc23 100644 --- a/src/interface/web/app/components/chatHistory/chatHistory.tsx +++ b/src/interface/web/app/components/chatHistory/chatHistory.tsx @@ -13,7 +13,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { InlineLoading } from "../loading/loading"; -import { Lightbulb } from "@phosphor-icons/react"; +import { Lightbulb, ArrowDown } from "@phosphor-icons/react"; import ProfileCard from "../profileCard/profileCard"; import { getIconFromIconName } from "@/app/common/iconUtils"; @@ -67,31 +67,49 @@ export default function ChatHistory(props: ChatHistoryProps) { const [data, setData] = useState(null); const [currentPage, setCurrentPage] = useState(0); const [hasMoreMessages, setHasMoreMessages] = useState(true); - - const ref = useRef(null); - const chatHistoryRef = useRef(null); const sentinelRef = useRef(null); + const scrollAreaRef = useRef(null); + const latestUserMessageRef = useRef(null); + const latestFetchedMessageRef = useRef(null); const [incompleteIncomingMessageIndex, setIncompleteIncomingMessageIndex] = useState< number | null >(null); const [fetchingData, setFetchingData] = useState(false); + const [isNearBottom, setIsNearBottom] = useState(true); const isMobileWidth = useIsMobileWidth(); + const scrollAreaSelector = "[data-radix-scroll-area-viewport]"; + const fetchMessageCount = 10; useEffect(() => { - // This function ensures that scrolling to bottom happens after the data (chat messages) has been updated and rendered the first time. - const scrollToBottomAfterDataLoad = () => { - // Assume the data is loading in this scenario. - if (!data?.chat.length) { - setTimeout(() => { - scrollToBottom(); - }, 500); - } + const scrollAreaEl = scrollAreaRef.current?.querySelector(scrollAreaSelector); + if (!scrollAreaEl) return; + + const detectIsNearBottom = () => { + const { scrollTop, scrollHeight, clientHeight } = scrollAreaEl; + const bottomThreshold = 50; // pixels from bottom + const distanceFromBottom = scrollHeight - (scrollTop + clientHeight); + const isNearBottom = distanceFromBottom <= bottomThreshold; + setIsNearBottom(isNearBottom); }; - if (currentPage < 2) { - // Call the function defined above. - scrollToBottomAfterDataLoad(); + scrollAreaEl.addEventListener("scroll", detectIsNearBottom); + return () => scrollAreaEl.removeEventListener("scroll", detectIsNearBottom); + }, []); + + // Auto scroll while incoming message is streamed + useEffect(() => { + if (props.incomingMessages && props.incomingMessages.length > 0 && isNearBottom) { + setTimeout(scrollToBottom, 0); + } + }, [props.incomingMessages, isNearBottom]); + + // Scroll to most recent user message after the first page of chat messages is loaded. + useEffect(() => { + if (data && data.chat && data.chat.length > 0 && currentPage < 2) { + setTimeout(() => { + latestUserMessageRef.current?.scrollIntoView({ behavior: "auto", block: "start" }); + }, 0); } }, [data, currentPage]); @@ -104,7 +122,6 @@ export default function ChatHistory(props: ChatHistoryProps) { if (entries[0].isIntersecting && hasMoreMessages) { setFetchingData(true); fetchMoreMessages(currentPage); - setCurrentPage((prev) => prev + 1); } }, { threshold: 1.0 }, @@ -131,22 +148,28 @@ export default function ChatHistory(props: ChatHistoryProps) { setIncompleteIncomingMessageIndex(props.incomingMessages.length - 1); } } - - if (isUserAtBottom()) { - scrollToBottom(); - } }, [props.incomingMessages]); + const adjustScrollPosition = () => { + const scrollAreaEl = scrollAreaRef.current?.querySelector(scrollAreaSelector); + requestAnimationFrame(() => { + // Snap scroll position to the latest fetched message ref + latestFetchedMessageRef.current?.scrollIntoView({ behavior: "auto", block: "start" }); + // Now scroll up smoothly to render user scroll action + scrollAreaEl?.scrollBy({ behavior: "smooth", top: -150 }); + }); + }; + function fetchMoreMessages(currentPage: number) { if (!hasMoreMessages || fetchingData) return; const nextPage = currentPage + 1; - + const maxMessagesToFetch = nextPage * fetchMessageCount; let conversationFetchURL = ""; if (props.conversationId) { - conversationFetchURL = `/api/chat/history?client=web&conversation_id=${encodeURIComponent(props.conversationId)}&n=${10 * nextPage}`; + conversationFetchURL = `/api/chat/history?client=web&conversation_id=${encodeURIComponent(props.conversationId)}&n=${maxMessagesToFetch}`; } else if (props.publicConversationSlug) { - conversationFetchURL = `/api/chat/share/history?client=web&public_conversation_slug=${props.publicConversationSlug}&n=${10 * nextPage}`; + conversationFetchURL = `/api/chat/share/history?client=web&public_conversation_slug=${props.publicConversationSlug}&n=${maxMessagesToFetch}`; } else { return; } @@ -161,19 +184,20 @@ export default function ChatHistory(props: ChatHistoryProps) { chatData.response.chat && chatData.response.chat.length > 0 ) { + setCurrentPage(Math.ceil(chatData.response.chat.length / fetchMessageCount)); if (chatData.response.chat.length === data?.chat.length) { setHasMoreMessages(false); setFetchingData(false); return; } props.setAgent(chatData.response.agent); - setData(chatData.response); - - if (currentPage < 2) { - scrollToBottom(); - } setFetchingData(false); + if (currentPage === 0) { + scrollToBottom(true); + } else { + adjustScrollPosition(); + } } else { if (chatData.response.agent && chatData.response.conversation_id) { const chatMetadata = { @@ -196,22 +220,15 @@ export default function ChatHistory(props: ChatHistoryProps) { }); } - const scrollToBottom = () => { - if (chatHistoryRef.current) { - chatHistoryRef.current.scrollIntoView(false); - } - }; - - const isUserAtBottom = () => { - if (!chatHistoryRef.current) return false; - - // NOTE: This isn't working. It always seems to return true. This is because - - const { scrollTop, scrollHeight, clientHeight } = chatHistoryRef.current as HTMLDivElement; - const threshold = 25; // pixels from the bottom - - // Considered at the bottom if within threshold pixels from the bottom - return scrollTop + clientHeight >= scrollHeight - threshold; + const scrollToBottom = (instant: boolean = false) => { + const scrollAreaEl = scrollAreaRef.current?.querySelector(scrollAreaSelector); + requestAnimationFrame(() => { + scrollAreaEl?.scrollTo({ + top: scrollAreaEl.scrollHeight, + behavior: instant ? "auto" : "smooth", + }); + }); + setIsNearBottom(true); }; function constructAgentLink() { @@ -232,10 +249,11 @@ export default function ChatHistory(props: ChatHistoryProps) { if (!props.conversationId && !props.publicConversationSlug) { return null; } + return ( - -
-
+ +
+
{fetchingData && ( @@ -246,6 +264,17 @@ export default function ChatHistory(props: ChatHistoryProps) { data.chat.map((chatMessage, index) => ( )}
+ {!isNearBottom && ( + + )}
); diff --git a/src/interface/web/app/components/chatMessage/chatMessage.module.css b/src/interface/web/app/components/chatMessage/chatMessage.module.css index 7890fc2b..2001317e 100644 --- a/src/interface/web/app/components/chatMessage/chatMessage.module.css +++ b/src/interface/web/app/components/chatMessage/chatMessage.module.css @@ -53,6 +53,10 @@ div.khojChatMessage { padding-left: 16px; } +div.emptyChatMessage { + display: none; +} + div.chatMessageContainer img { width: 50%; } diff --git a/src/interface/web/app/components/chatMessage/chatMessage.tsx b/src/interface/web/app/components/chatMessage/chatMessage.tsx index ea3f0f7c..1a345912 100644 --- a/src/interface/web/app/components/chatMessage/chatMessage.tsx +++ b/src/interface/web/app/components/chatMessage/chatMessage.tsx @@ -4,7 +4,7 @@ import styles from "./chatMessage.module.css"; import markdownIt from "markdown-it"; import mditHljs from "markdown-it-highlightjs"; -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useRef, useState, forwardRef } from "react"; import { createRoot } from "react-dom/client"; import "katex/dist/katex.min.css"; @@ -275,7 +275,7 @@ export function TrainOfThought(props: TrainOfThoughtProps) { ); } -export default function ChatMessage(props: ChatMessageProps) { +const ChatMessage = forwardRef((props, ref) => { const [copySuccess, setCopySuccess] = useState(false); const [isHovering, setIsHovering] = useState(false); const [textRendered, setTextRendered] = useState(""); @@ -406,10 +406,6 @@ export default function ChatMessage(props: ChatMessageProps) { } }, [markdownRendered, isHovering, messageRef]); - if (!props.chatMessage.message) { - return null; - } - function formatDate(timestamp: string) { // Format date in HH:MM, DD MMM YYYY format let date = new Date(timestamp + "Z"); @@ -449,6 +445,9 @@ export default function ChatMessage(props: ChatMessageProps) { function constructClasses(chatMessage: SingleChatMessage) { let classes = [styles.chatMessageContainer, "shadow-md"]; classes.push(styles[chatMessage.by]); + if (!chatMessage.message) { + classes.push(styles.emptyChatMessage); + } if (props.customClassName) { classes.push(styles[`${chatMessage.by}${props.customClassName}`]); @@ -478,17 +477,8 @@ export default function ChatMessage(props: ChatMessageProps) { const sentenceRegex = /[^.!?]+[.!?]*/g; const chunks = props.chatMessage.message.match(sentenceRegex) || []; - if (!chunks) { - return; - } + if (!chunks || chunks.length === 0 || !chunks[0]) return; - if (chunks.length === 0) { - return; - } - - if (!chunks[0]) { - return; - } setIsPlaying(true); let nextBlobPromise = fetchBlob(chunks[0]); @@ -548,6 +538,7 @@ export default function ChatMessage(props: ChatMessageProps) { return (
setIsHovering(false)} onMouseEnter={(event) => setIsHovering(true)} @@ -640,4 +631,8 @@ export default function ChatMessage(props: ChatMessageProps) {
); -} +}); + +ChatMessage.displayName = "ChatMessage"; + +export default ChatMessage;