Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Only Live Scrolling when at Page Bottom and Add Button to Scroll to Page Bottom on Web App #923

Merged
merged 9 commits into from
Sep 29, 2024
135 changes: 88 additions & 47 deletions src/interface/web/app/components/chatHistory/chatHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -67,31 +67,49 @@ export default function ChatHistory(props: ChatHistoryProps) {
const [data, setData] = useState<ChatHistoryData | null>(null);
const [currentPage, setCurrentPage] = useState(0);
const [hasMoreMessages, setHasMoreMessages] = useState(true);

const ref = useRef<HTMLDivElement>(null);
const chatHistoryRef = useRef<HTMLDivElement | null>(null);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
const latestUserMessageRef = useRef<HTMLDivElement | null>(null);
const latestFetchedMessageRef = useRef<HTMLDivElement | null>(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<HTMLElement>(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]);

Expand All @@ -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 },
Expand All @@ -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<HTMLElement>(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;
}
Expand All @@ -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 = {
Expand All @@ -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<HTMLElement>(scrollAreaSelector);
requestAnimationFrame(() => {
scrollAreaEl?.scrollTo({
top: scrollAreaEl.scrollHeight,
behavior: instant ? "auto" : "smooth",
});
});
setIsNearBottom(true);
};

function constructAgentLink() {
Expand All @@ -232,10 +249,11 @@ export default function ChatHistory(props: ChatHistoryProps) {
if (!props.conversationId && !props.publicConversationSlug) {
return null;
}

return (
<ScrollArea className={`h-[80vh]`}>
<div ref={ref}>
<div className={styles.chatHistory} ref={chatHistoryRef}>
<ScrollArea className={`h-[80vh] relative`} ref={scrollAreaRef}>
<div>
<div className={styles.chatHistory}>
<div ref={sentinelRef} style={{ height: "1px" }}>
{fetchingData && (
<InlineLoading message="Loading Conversation" className="opacity-50" />
Expand All @@ -246,6 +264,17 @@ export default function ChatHistory(props: ChatHistoryProps) {
data.chat.map((chatMessage, index) => (
<ChatMessage
key={`${index}fullHistory`}
ref={
// attach ref to the second last message to handle scroll on page load
index === data.chat.length - 2
? latestUserMessageRef
: // attach ref to the newest fetched message to handle scroll on fetch
// note: stabilize index selection against last page having less messages than fetchMessageCount
index ===
data.chat.length - (currentPage - 1) * fetchMessageCount
? latestFetchedMessageRef
: null
}
isMobileWidth={isMobileWidth}
chatMessage={chatMessage}
customClassName="fullHistory"
Expand Down Expand Up @@ -334,6 +363,18 @@ export default function ChatHistory(props: ChatHistoryProps) {
</div>
)}
</div>
{!isNearBottom && (
<button
title="Scroll to bottom"
className="absolute bottom-4 right-5 bg-white dark:bg-[hsl(var(--background))] text-neutral-500 dark:text-white p-2 rounded-full shadow-xl"
onClick={() => {
scrollToBottom();
setIsNearBottom(true);
}}
>
<ArrowDown size={24} />
</button>
)}
</div>
</ScrollArea>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ div.khojChatMessage {
padding-left: 16px;
}

div.emptyChatMessage {
display: none;
}

div.chatMessageContainer img {
width: 50%;
}
Expand Down
29 changes: 12 additions & 17 deletions src/interface/web/app/components/chatMessage/chatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -275,7 +275,7 @@ export function TrainOfThought(props: TrainOfThoughtProps) {
);
}

export default function ChatMessage(props: ChatMessageProps) {
const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) => {
const [copySuccess, setCopySuccess] = useState<boolean>(false);
const [isHovering, setIsHovering] = useState<boolean>(false);
const [textRendered, setTextRendered] = useState<string>("");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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}`]);
Expand Down Expand Up @@ -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.length === 0) {
return;
}
if (!chunks || chunks.length === 0 || !chunks[0]) return;

if (!chunks[0]) {
return;
}
setIsPlaying(true);

let nextBlobPromise = fetchBlob(chunks[0]);
Expand Down Expand Up @@ -548,6 +538,7 @@ export default function ChatMessage(props: ChatMessageProps) {

return (
<div
ref={ref}
className={constructClasses(props.chatMessage)}
onMouseLeave={(event) => setIsHovering(false)}
onMouseEnter={(event) => setIsHovering(true)}
Expand Down Expand Up @@ -640,4 +631,8 @@ export default function ChatMessage(props: ChatMessageProps) {
</div>
</div>
);
}
});

ChatMessage.displayName = "ChatMessage";

export default ChatMessage;
Loading