Only Auto Scroll when at Page Bottom and Add Button to Scroll to Page Bottom on Web App (#923)

Improve Scrolling on Chat page of Web app

- Details
  1. Only auto scroll Khoj's streamed response when scroll is near bottom of page
      Allows scrolling to other messages in conversation while Khoj is formulating and streaming its response
  2. Add button to scroll to bottom of the chat page
  3. Scroll to most recent conversation turn on conversation first load
      It's a better default to anchor to most recent conversation turn (i.e most recent user message)
  4. Smooth scroll when Khoj's chat response is streamed
      Previously the scroll would jitter during response streaming
  5. Anchor scroll position when fetch and render older messages in conversation
      Allow users to keep their scroll position when older messages are fetched from server and rendered

Resolves #758
This commit is contained in:
Shantanu Sakpal 2024-09-29 11:24:34 +05:30 committed by GitHub
parent 06777e1660
commit be8de1a1bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 104 additions and 64 deletions

View file

@ -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<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]);
@ -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<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;
}
@ -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<HTMLElement>(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 (
<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" />
@ -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"
@ -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>
);

View file

@ -53,6 +53,10 @@ div.khojChatMessage {
padding-left: 16px;
}
div.emptyChatMessage {
display: none;
}
div.chatMessageContainer img {
width: 50%;
}

View file

@ -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<HTMLDivElement, ChatMessageProps>((props, ref) => {
const [copySuccess, setCopySuccess] = useState<boolean>(false);
const [isHovering, setIsHovering] = useState<boolean>(false);
const [textRendered, setTextRendered] = useState<string>("");
@ -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 (
<div
ref={ref}
className={constructClasses(props.chatMessage)}
onMouseLeave={(event) => setIsHovering(false)}
onMouseEnter={(event) => setIsHovering(true)}
@ -640,4 +631,8 @@ export default function ChatMessage(props: ChatMessageProps) {
</div>
</div>
);
}
});
ChatMessage.displayName = "ChatMessage";
export default ChatMessage;