diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx
index f9ee1b80a..af6a284b9 100644
--- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx
@@ -1,4 +1,11 @@
-import { useEffect, useRef, useState, useMemo, useCallback } from "react";
+import {
+ useEffect,
+ useRef,
+ useState,
+ useMemo,
+ useCallback,
+ forwardRef,
+} from "react";
import HistoricalMessage from "./HistoricalMessage";
import PromptReply from "./PromptReply";
import StatusResponse from "./StatusResponse";
@@ -13,25 +20,29 @@ import { useParams } from "react-router-dom";
import paths from "@/utils/paths";
import Appearance from "@/models/appearance";
import useTextSize from "@/hooks/useTextSize";
+import useChatHistoryScrollHandle from "@/hooks/useChatHistoryScrollHandle";
import { v4 } from "uuid";
import { useTranslation } from "react-i18next";
import { useChatMessageAlignment } from "@/hooks/useChatMessageAlignment";
-export default function ChatHistory({
- history = [],
- workspace,
- sendCommand,
- updateHistory,
- regenerateAssistantMessage,
- hasAttachments = false,
-}) {
+export default forwardRef(function (
+ {
+ history = [],
+ workspace,
+ sendCommand,
+ updateHistory,
+ regenerateAssistantMessage,
+ hasAttachments = false,
+ },
+ ref
+) {
const { t } = useTranslation();
const lastScrollTopRef = useRef(0);
+ const chatHistoryRef = useRef(null);
const { user } = useUser();
const { threadSlug = null } = useParams();
const { showing, showModal, hideModal } = useManageWorkspaceModal();
const [isAtBottom, setIsAtBottom] = useState(true);
- const chatHistoryRef = useRef(null);
const [isUserScrolling, setIsUserScrolling] = useState(false);
const isStreaming = history[history.length - 1]?.animate;
const { showScrollbar } = Appearance.getSettings();
@@ -81,6 +92,12 @@ export default function ChatHistory({
}
};
+ useChatHistoryScrollHandle(ref, chatHistoryRef, {
+ setIsUserScrolling,
+ isStreaming,
+ scrollToBottom,
+ });
+
const handleSendSuggestedMessage = (heading, message) => {
sendCommand({ text: `${heading} ${message}`, autoSubmit: true });
};
@@ -239,7 +256,7 @@ export default function ChatHistory({
{
- scrollToBottom(true);
+ scrollToBottom(isStreaming ? false : true);
setIsUserScrolling(false);
}}
>
@@ -250,7 +267,7 @@ export default function ChatHistory({
)}
);
-}
+});
const getLastMessageInfo = (history) => {
const lastMessage = history?.[history.length - 1] || {};
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx
index ee57186f5..cae3fb7c0 100644
--- a/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx
@@ -22,6 +22,7 @@ import SpeechRecognition, {
} from "react-speech-recognition";
import { ChatTooltips } from "./ChatTooltips";
import { MetricsProvider } from "./ChatHistory/HistoricalMessage/Actions/RenderMetrics";
+import useChatContainerQuickScroll from "@/hooks/useChatContainerQuickScroll";
export default function ChatContainer({ workspace, knownHistory = [] }) {
const { threadSlug = null } = useParams();
@@ -31,6 +32,7 @@ export default function ChatContainer({ workspace, knownHistory = [] }) {
const [socketId, setSocketId] = useState(null);
const [websocket, setWebsocket] = useState(null);
const { files, parseAttachments } = useContext(DndUploaderContext);
+ const { chatHistoryRef } = useChatContainerQuickScroll();
// Maintain state of message from whatever is in PromptInput
const handleMessageChange = (event) => {
@@ -308,6 +310,7 @@ export default function ChatContainer({ workspace, knownHistory = [] }) {
{
+ event.preventDefault();
+ chatHistoryRef.current.scrollToTop();
+ };
+
+ const scrollToBottom = (event) => {
+ event.preventDefault();
+ chatHistoryRef.current.scrollToBottom();
+ };
+
+ useEffect(() => {
+ function handleScrollShortcuts(event) {
+ const modifierPressed = isMac ? event.metaKey : event.ctrlKey;
+ if (!modifierPressed || !chatHistoryRef.current) return;
+ if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
+
+ switch (event.key) {
+ case "ArrowUp":
+ event.preventDefault();
+ scrollToTop(event);
+ break;
+ case "ArrowDown":
+ event.preventDefault();
+ scrollToBottom(event);
+ break;
+ default:
+ break;
+ }
+ }
+
+ window.addEventListener("keydown", handleScrollShortcuts);
+ return () => window.removeEventListener("keydown", handleScrollShortcuts);
+ }, []);
+
+ return { chatHistoryRef };
+}
diff --git a/frontend/src/hooks/useChatHistoryScrollHandle.js b/frontend/src/hooks/useChatHistoryScrollHandle.js
new file mode 100644
index 000000000..1b5435f8a
--- /dev/null
+++ b/frontend/src/hooks/useChatHistoryScrollHandle.js
@@ -0,0 +1,38 @@
+import { useImperativeHandle } from "react";
+
+/**
+ * Exposes scroll control methods (scrollToTop, scrollToBottom) via a forwarded ref.
+ * This allows parent components to programmatically scroll the chat history.
+ *
+ * @param {React.Ref} ref - The forwarded ref from the parent component
+ * @param {React.RefObject} chatHistoryRef - Ref to the scrollable chat history DOM element
+ * @param {Object} options - Configuration options
+ * @param {Function} options.setIsUserScrolling - Setter to mark user-initiated scrolling
+ * @param {boolean} options.isStreaming - Whether chat is currently streaming a response
+ * @param {Function} options.scrollToBottom - Internal scroll to bottom function
+ */
+export default function useChatHistoryScrollHandle(
+ ref,
+ chatHistoryRef,
+ { setIsUserScrolling, isStreaming, scrollToBottom }
+) {
+ useImperativeHandle(
+ ref,
+ () => ({
+ scrollToTop() {
+ if (chatHistoryRef.current) {
+ setIsUserScrolling(true);
+ chatHistoryRef.current.scrollTo({
+ top: 0,
+ behavior: "smooth",
+ });
+ }
+ },
+ scrollToBottom() {
+ setIsUserScrolling(true);
+ scrollToBottom(isStreaming ? false : true);
+ },
+ }),
+ [isStreaming]
+ );
+}