mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-18 17:30:16 +01:00
feat: auto-scroll to the bottom of the conversation (#1049)
* added button for autoscroll
* fix(General) removed bold
* fix(General) typescript error with checked={autoScroll}
* added return condition for new conversations
* refactor(Message) limit nesting
* fix(settings) used effects
* fix(Message) disabled autoscroll when search
* test(AutoScrollSwitch)
* fix(AutoScrollSwitch) test
* fix(ci): attempt to debug workflow
* refactor: move AutoScrollSwitch from General file, don't use cache for npm
* fix(ci): add test config to avoid redirects and silentRefresh
* chore: add back workflow caching
* chore(AutoScrollSwitch): remove comments, fix type issues, clarify switch intent
* refactor(Message): remove unnecessary message prop form scrolling condition
* fix(AutoScrollSwitch.spec): do not get by text
---------
Co-authored-by: Danny Avila <messagedaniel@protonmail.com>
This commit is contained in:
parent
cff45df0ef
commit
b1a96ecedc
12 changed files with 158 additions and 37 deletions
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { useGetConversationByIdQuery } from 'librechat-data-provider';
|
||||
import { useEffect } from 'react';
|
||||
import { useSetRecoilState, useRecoilState } from 'recoil';
|
||||
import { useSetRecoilState, useRecoilState, useRecoilValue } from 'recoil';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { SubRow, Plugin, MessageContent } from './Content';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
|
|
@ -13,21 +13,27 @@ import { useMessageHandler, useConversation } from '~/hooks';
|
|||
import type { TMessageProps } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
export default function Message(props: TMessageProps) {
|
||||
const {
|
||||
conversation,
|
||||
message,
|
||||
scrollToBottom,
|
||||
currentEditId,
|
||||
setCurrentEditId,
|
||||
siblingIdx,
|
||||
siblingCount,
|
||||
setSiblingIdx,
|
||||
} = props;
|
||||
|
||||
export default function Message({
|
||||
conversation,
|
||||
message,
|
||||
scrollToBottom,
|
||||
currentEditId,
|
||||
setCurrentEditId,
|
||||
siblingIdx,
|
||||
siblingCount,
|
||||
setSiblingIdx,
|
||||
}: TMessageProps) {
|
||||
const setLatestMessage = useSetRecoilState(store.latestMessage);
|
||||
const [abortScroll, setAbortScroll] = useRecoilState(store.abortScroll);
|
||||
const { isSubmitting, ask, regenerate, handleContinue } = useMessageHandler();
|
||||
const { switchToConversation } = useConversation();
|
||||
const { conversationId } = useParams();
|
||||
const isSearching = useRecoilValue(store.isSearching);
|
||||
|
||||
const {
|
||||
text,
|
||||
children,
|
||||
|
|
@ -37,24 +43,26 @@ export default function Message({
|
|||
error,
|
||||
unfinished,
|
||||
} = message ?? {};
|
||||
|
||||
const isLast = !children?.length;
|
||||
const edit = messageId == currentEditId;
|
||||
const edit = messageId === currentEditId;
|
||||
const getConversationQuery = useGetConversationByIdQuery(message?.conversationId ?? '', {
|
||||
enabled: false,
|
||||
});
|
||||
const blinker = message?.submitting && isSubmitting;
|
||||
|
||||
// debugging
|
||||
// useEffect(() => {
|
||||
// console.log('isSubmitting:', isSubmitting);
|
||||
// console.log('unfinished:', unfinished);
|
||||
// }, [isSubmitting, unfinished]);
|
||||
const autoScroll = useRecoilValue(store.autoScroll);
|
||||
|
||||
useEffect(() => {
|
||||
if (blinker && scrollToBottom && !abortScroll) {
|
||||
if (isSubmitting && scrollToBottom && !abortScroll) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [isSubmitting, blinker, text, scrollToBottom]);
|
||||
}, [isSubmitting, text, scrollToBottom, abortScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollToBottom && autoScroll && !isSearching && conversationId !== 'new') {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [autoScroll, conversationId, scrollToBottom, isSearching]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!message) {
|
||||
|
|
@ -62,7 +70,7 @@ export default function Message({
|
|||
} else if (isLast) {
|
||||
setLatestMessage({ ...message });
|
||||
}
|
||||
}, [isLast, message]);
|
||||
}, [isLast, message, setLatestMessage]);
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
|
|
@ -72,7 +80,7 @@ export default function Message({
|
|||
setCurrentEditId && setCurrentEditId(cancel ? -1 : messageId);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (blinker) {
|
||||
if (isSubmitting) {
|
||||
setAbortScroll(true);
|
||||
} else {
|
||||
setAbortScroll(false);
|
||||
|
|
@ -85,7 +93,7 @@ export default function Message({
|
|||
? 'bg-white dark:bg-gray-800 dark:text-gray-20'
|
||||
: 'bg-gray-50 dark:bg-gray-1000 dark:text-gray-70';
|
||||
|
||||
const props = {
|
||||
const messageProps = {
|
||||
className: cn(commonClasses, uniqueClasses),
|
||||
titleclass: '',
|
||||
};
|
||||
|
|
@ -98,8 +106,8 @@ export default function Message({
|
|||
});
|
||||
|
||||
if (message?.bg && searchResult) {
|
||||
props.className = message?.bg?.split('hover')[0];
|
||||
props.titleclass = message?.bg?.split(props.className)[1] + ' cursor-pointer';
|
||||
messageProps.className = message?.bg?.split('hover')[0];
|
||||
messageProps.titleclass = message?.bg?.split(messageProps.className)[1] + ' cursor-pointer';
|
||||
}
|
||||
|
||||
const regenerateMessage = () => {
|
||||
|
|
@ -124,17 +132,20 @@ export default function Message({
|
|||
if (!message) {
|
||||
return;
|
||||
}
|
||||
getConversationQuery.refetch({ queryKey: [message?.conversationId] }).then((response) => {
|
||||
console.log('getConversationQuery response.data:', response.data);
|
||||
if (response.data) {
|
||||
switchToConversation(response.data);
|
||||
}
|
||||
const response = await getConversationQuery.refetch({
|
||||
queryKey: [message?.conversationId],
|
||||
});
|
||||
|
||||
console.log('getConversationQuery response.data:', response.data);
|
||||
|
||||
if (response.data) {
|
||||
switchToConversation(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div {...props} onWheel={handleScroll} onTouchMove={handleScroll}>
|
||||
<div {...messageProps} onWheel={handleScroll} onTouchMove={handleScroll}>
|
||||
<div className="relative m-auto flex gap-4 p-4 text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
|
||||
<div className="relative flex h-[40px] w-[40px] flex-col items-end text-right text-xs md:text-sm">
|
||||
{typeof icon === 'string' && /[^\\x00-\\x7F]+/.test(icon as string) ? (
|
||||
|
|
@ -153,7 +164,7 @@ export default function Message({
|
|||
<div className="relative flex w-[calc(100%-50px)] flex-col gap-1 md:gap-3 lg:w-[calc(100%-115px)]">
|
||||
{searchResult && (
|
||||
<SubRow
|
||||
classes={props.titleclass + ' rounded'}
|
||||
classes={messageProps.titleclass + ' rounded'}
|
||||
subclasses="switch-result pl-2 pb-2"
|
||||
onClick={clickSearchResult}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { render, fireEvent } from 'test/layout-test-utils';
|
||||
import AutoScrollSwitch from './AutoScrollSwitch';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
|
||||
describe('AutoScrollSwitch', () => {
|
||||
/**
|
||||
* Mock function to set the auto-scroll state.
|
||||
*/
|
||||
let mockSetAutoScroll: jest.Mock<void, [boolean]> | ((value: boolean) => void) | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSetAutoScroll = jest.fn();
|
||||
});
|
||||
|
||||
it('renders correctly', () => {
|
||||
const { getByTestId } = render(
|
||||
<RecoilRoot>
|
||||
<AutoScrollSwitch />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
expect(getByTestId('autoScroll')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onCheckedChange when the switch is toggled', () => {
|
||||
const { getByTestId } = render(
|
||||
<RecoilRoot>
|
||||
<AutoScrollSwitch onCheckedChange={mockSetAutoScroll} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
const switchElement = getByTestId('autoScroll');
|
||||
fireEvent.click(switchElement);
|
||||
|
||||
expect(mockSetAutoScroll).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
33
client/src/components/Nav/SettingsTabs/AutoScrollSwitch.tsx
Normal file
33
client/src/components/Nav/SettingsTabs/AutoScrollSwitch.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
export default function AutoScrollSwitch({
|
||||
onCheckedChange,
|
||||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const [autoScroll, setAutoScroll] = useRecoilState<boolean>(store.autoScroll);
|
||||
const localize = useLocalize();
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setAutoScroll(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_auto_scroll')}</div>
|
||||
<Switch
|
||||
id="autoScroll"
|
||||
checked={autoScroll}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4 mt-2"
|
||||
data-testid="autoScroll"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,11 +8,12 @@ import {
|
|||
useOnClickOutside,
|
||||
useConversation,
|
||||
useConversations,
|
||||
useLocalStorage,
|
||||
} from '~/hooks';
|
||||
import type { TDangerButtonProps } from '~/common';
|
||||
import AutoScrollSwitch from './AutoScrollSwitch';
|
||||
import DangerButton from './DangerButton';
|
||||
import store from '~/store';
|
||||
import useLocalStorage from '~/hooks/useLocalStorage';
|
||||
|
||||
export const ThemeSelector = ({
|
||||
theme,
|
||||
|
|
@ -175,6 +176,9 @@ function General() {
|
|||
mutation={clearConvosMutation}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
|
||||
<AutoScrollSwitch />
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue