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:
Marco Beretta 2023-10-16 17:01:38 +02:00 committed by GitHub
parent cff45df0ef
commit b1a96ecedc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 158 additions and 37 deletions

View file

@ -34,4 +34,5 @@ jobs:
run: npm run frontend:ci
- name: Run unit tests
run: cd client && npm run test:ci
run: npm run test:ci --verbose
working-directory: client

View file

@ -8,8 +8,8 @@
"build:ci": "cross-env NODE_ENV=development vite build --mode ci",
"dev": "cross-env NODE_ENV=development vite",
"preview-prod": "cross-env NODE_ENV=development vite preview",
"test": "cross-env NODE_ENV=test jest --watch",
"test:ci": "cross-env NODE_ENV=test jest --ci",
"test": "cross-env NODE_ENV=development jest --watch",
"test:ci": "cross-env NODE_ENV=development jest --ci",
"b:test": "NODE_ENV=test bunx jest --watch",
"b:build": "NODE_ENV=production bun --bun vite build",
"b:dev": "NODE_ENV=development bunx vite"

View file

@ -177,6 +177,7 @@ export type TUserContext = {
export type TAuthConfig = {
loginRedirect: string;
test?: boolean;
};
export type IconProps = Pick<TMessage, 'isCreatedByUser' | 'model' | 'error'> &

View file

@ -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,8 +13,10 @@ 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({
export default function Message(props: TMessageProps) {
const {
conversation,
message,
scrollToBottom,
@ -23,11 +25,15 @@ export default function Message({
siblingIdx,
siblingCount,
setSiblingIdx,
}: TMessageProps) {
} = props;
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) => {
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}
>

View file

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

View 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>
);
}

View file

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

View file

@ -24,7 +24,7 @@ import useTimeout from './useTimeout';
const AuthContext = createContext<TAuthContext | undefined>(undefined);
const AuthContextProvider = ({
// authConfig,
authConfig,
children,
}: {
authConfig?: TAuthConfig;
@ -98,6 +98,10 @@ const AuthContextProvider = ({
}, [setUserContext, doSetError, logoutUser]);
const silentRefresh = useCallback(() => {
if (authConfig?.test) {
console.log('Test mode. Skipping silent refresh.');
return;
}
refreshToken.mutate(undefined, {
onSuccess: (data: TLoginResponse) => {
const { user, token } = data;
@ -105,11 +109,17 @@ const AuthContextProvider = ({
setUserContext({ token, isAuthenticated: true, user });
} else {
console.log('Token is not present. User is not authenticated.');
if (authConfig?.test) {
return;
}
navigate('/login');
}
},
onError: (error) => {
console.log('refreshToken mutation error:', error);
if (authConfig?.test) {
return;
}
navigate('/login');
},
});

View file

@ -226,6 +226,7 @@ export default {
com_endpoint_config_key_google_service_account: 'Create a Service Account',
com_endpoint_config_key_google_vertex_api_role:
'Make sure to click \'Create and Continue\' to give at least the \'Vertex AI User\' role. Lastly, create a JSON key to import here.',
com_nav_auto_scroll: 'Auto-scroll to Newest on Open',
com_nav_plugin_store: 'Plugin store',
com_nav_plugin_search: 'Search plugins',
com_nav_plugin_auth_error:

View file

@ -226,6 +226,7 @@ export default {
com_endpoint_config_key_google_service_account: 'Crea un account di servizio',
com_endpoint_config_key_google_vertex_api_role:
'Assicurati di fare clic su \'Crea e continua\' per dare almeno il ruolo \'Utente Vertex AI\'. Infine, crea una chiave JSON da importare qui.',
com_nav_auto_scroll: 'Scorrimento automatico',
com_nav_plugin_store: 'Negozio dei plugin',
com_nav_plugin_search: 'Cerca plugin',
com_nav_plugin_auth_error:

View file

@ -35,6 +35,25 @@ const showPopover = atom<boolean>({
default: false,
});
const autoScroll = atom<boolean>({
key: 'autoScroll',
default: localStorage.getItem('autoScroll') === 'true',
effects: [
({ setSelf, onSet }) => {
const savedValue = localStorage.getItem('autoScroll');
if (savedValue != null) {
setSelf(savedValue === 'true');
}
onSet((newValue: unknown) => {
if (typeof newValue === 'boolean') {
localStorage.setItem('autoScroll', newValue.toString());
}
});
},
] as const,
});
export default {
abortScroll,
optionSettings,
@ -42,4 +61,5 @@ export default {
showAgentSettings,
showBingToneSetting,
showPopover,
autoScroll,
};

View file

@ -17,6 +17,7 @@ function renderWithProvidersWrapper(ui, { ...options } = {}) {
<AuthContextProvider
authConfig={{
loginRedirect: '',
test: true,
}}
>
{children}