Add keyboard shortcuts to scroll to top and bottom of chat history (#4870)

* Add keybindings to scroll to top and bottom of chat history

* fix isUserScrolling flag and set scrollToBottom to be instant instead of smoothe

* fix stream scroll

* fix default export by removing unneeded constant

* Replace file-defined `isMac` variable with global util

* extract funcitonality to hooks for clarity

* patch import

---------

Co-authored-by: shatfield4 <seanhatfield5@gmail.com>
Co-authored-by: Timothy Carambat <rambat1010@gmail.com>
This commit is contained in:
Marcello Fitton
2026-01-26 14:20:08 -08:00
committed by GitHub
parent f52e2866ac
commit 39e6ccdaa3
4 changed files with 116 additions and 12 deletions

View File

@@ -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({
<div
className="p-1 rounded-full border border-white/10 bg-white/10 hover:bg-white/20 hover:text-white"
onClick={() => {
scrollToBottom(true);
scrollToBottom(isStreaming ? false : true);
setIsUserScrolling(false);
}}
>
@@ -250,7 +267,7 @@ export default function ChatHistory({
)}
</div>
);
}
});
const getLastMessageInfo = (history) => {
const lastMessage = history?.[history.length - 1] || {};

View File

@@ -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 = [] }) {
<DnDFileUploaderWrapper>
<MetricsProvider>
<ChatHistory
ref={chatHistoryRef}
history={chatHistory}
workspace={workspace}
sendCommand={sendCommand}

View File

@@ -0,0 +1,46 @@
import { useEffect, useRef } from "react";
import { isMac } from "@/utils/keyboardShortcuts";
/**
* Hook for scrolling the chat container using keyboard shortcuts.
* @returns {Object} - An object containing the chat history ref and the scroll to top and bottom functions.
*/
export default function useChatContainerQuickScroll() {
const chatHistoryRef = useRef(null);
const scrollToTop = (event) => {
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 };
}

View File

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