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] + ); +}