mirror of
https://github.com/khoj-ai/khoj.git
synced 2024-11-27 17:35:07 +01:00
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:
parent
06777e1660
commit
be8de1a1bd
3 changed files with 104 additions and 64 deletions
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -53,6 +53,10 @@ div.khojChatMessage {
|
|||
padding-left: 16px;
|
||||
}
|
||||
|
||||
div.emptyChatMessage {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div.chatMessageContainer img {
|
||||
width: 50%;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue