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 run: npm run frontend:ci
- name: Run unit tests - 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", "build:ci": "cross-env NODE_ENV=development vite build --mode ci",
"dev": "cross-env NODE_ENV=development vite", "dev": "cross-env NODE_ENV=development vite",
"preview-prod": "cross-env NODE_ENV=development vite preview", "preview-prod": "cross-env NODE_ENV=development vite preview",
"test": "cross-env NODE_ENV=test jest --watch", "test": "cross-env NODE_ENV=development jest --watch",
"test:ci": "cross-env NODE_ENV=test jest --ci", "test:ci": "cross-env NODE_ENV=development jest --ci",
"b:test": "NODE_ENV=test bunx jest --watch", "b:test": "NODE_ENV=test bunx jest --watch",
"b:build": "NODE_ENV=production bun --bun vite build", "b:build": "NODE_ENV=production bun --bun vite build",
"b:dev": "NODE_ENV=development bunx vite" "b:dev": "NODE_ENV=development bunx vite"

View file

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

View file

@ -1,7 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import { useGetConversationByIdQuery } from 'librechat-data-provider'; import { useGetConversationByIdQuery } from 'librechat-data-provider';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useSetRecoilState, useRecoilState } from 'recoil'; import { useSetRecoilState, useRecoilState, useRecoilValue } from 'recoil';
import copy from 'copy-to-clipboard'; import copy from 'copy-to-clipboard';
import { SubRow, Plugin, MessageContent } from './Content'; import { SubRow, Plugin, MessageContent } from './Content';
// eslint-disable-next-line import/no-cycle // eslint-disable-next-line import/no-cycle
@ -13,21 +13,27 @@ import { useMessageHandler, useConversation } from '~/hooks';
import type { TMessageProps } from '~/common'; import type { TMessageProps } from '~/common';
import { cn } from '~/utils'; import { cn } from '~/utils';
import store from '~/store'; 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 setLatestMessage = useSetRecoilState(store.latestMessage);
const [abortScroll, setAbortScroll] = useRecoilState(store.abortScroll); const [abortScroll, setAbortScroll] = useRecoilState(store.abortScroll);
const { isSubmitting, ask, regenerate, handleContinue } = useMessageHandler(); const { isSubmitting, ask, regenerate, handleContinue } = useMessageHandler();
const { switchToConversation } = useConversation(); const { switchToConversation } = useConversation();
const { conversationId } = useParams();
const isSearching = useRecoilValue(store.isSearching);
const { const {
text, text,
children, children,
@ -37,24 +43,26 @@ export default function Message({
error, error,
unfinished, unfinished,
} = message ?? {}; } = message ?? {};
const isLast = !children?.length; const isLast = !children?.length;
const edit = messageId == currentEditId; const edit = messageId === currentEditId;
const getConversationQuery = useGetConversationByIdQuery(message?.conversationId ?? '', { const getConversationQuery = useGetConversationByIdQuery(message?.conversationId ?? '', {
enabled: false, enabled: false,
}); });
const blinker = message?.submitting && isSubmitting;
// debugging const autoScroll = useRecoilValue(store.autoScroll);
// useEffect(() => {
// console.log('isSubmitting:', isSubmitting);
// console.log('unfinished:', unfinished);
// }, [isSubmitting, unfinished]);
useEffect(() => { useEffect(() => {
if (blinker && scrollToBottom && !abortScroll) { if (isSubmitting && scrollToBottom && !abortScroll) {
scrollToBottom(); scrollToBottom();
} }
}, [isSubmitting, blinker, text, scrollToBottom]); }, [isSubmitting, text, scrollToBottom, abortScroll]);
useEffect(() => {
if (scrollToBottom && autoScroll && !isSearching && conversationId !== 'new') {
scrollToBottom();
}
}, [autoScroll, conversationId, scrollToBottom, isSearching]);
useEffect(() => { useEffect(() => {
if (!message) { if (!message) {
@ -62,7 +70,7 @@ export default function Message({
} else if (isLast) { } else if (isLast) {
setLatestMessage({ ...message }); setLatestMessage({ ...message });
} }
}, [isLast, message]); }, [isLast, message, setLatestMessage]);
if (!message) { if (!message) {
return null; return null;
@ -72,7 +80,7 @@ export default function Message({
setCurrentEditId && setCurrentEditId(cancel ? -1 : messageId); setCurrentEditId && setCurrentEditId(cancel ? -1 : messageId);
const handleScroll = () => { const handleScroll = () => {
if (blinker) { if (isSubmitting) {
setAbortScroll(true); setAbortScroll(true);
} else { } else {
setAbortScroll(false); setAbortScroll(false);
@ -85,7 +93,7 @@ export default function Message({
? 'bg-white dark:bg-gray-800 dark:text-gray-20' ? 'bg-white dark:bg-gray-800 dark:text-gray-20'
: 'bg-gray-50 dark:bg-gray-1000 dark:text-gray-70'; : 'bg-gray-50 dark:bg-gray-1000 dark:text-gray-70';
const props = { const messageProps = {
className: cn(commonClasses, uniqueClasses), className: cn(commonClasses, uniqueClasses),
titleclass: '', titleclass: '',
}; };
@ -98,8 +106,8 @@ export default function Message({
}); });
if (message?.bg && searchResult) { if (message?.bg && searchResult) {
props.className = message?.bg?.split('hover')[0]; messageProps.className = message?.bg?.split('hover')[0];
props.titleclass = message?.bg?.split(props.className)[1] + ' cursor-pointer'; messageProps.titleclass = message?.bg?.split(messageProps.className)[1] + ' cursor-pointer';
} }
const regenerateMessage = () => { const regenerateMessage = () => {
@ -124,17 +132,20 @@ export default function Message({
if (!message) { if (!message) {
return; return;
} }
getConversationQuery.refetch({ queryKey: [message?.conversationId] }).then((response) => { const response = await getConversationQuery.refetch({
console.log('getConversationQuery response.data:', response.data); queryKey: [message?.conversationId],
if (response.data) {
switchToConversation(response.data);
}
}); });
console.log('getConversationQuery response.data:', response.data);
if (response.data) {
switchToConversation(response.data);
}
}; };
return ( 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 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"> <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) ? ( {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)]"> <div className="relative flex w-[calc(100%-50px)] flex-col gap-1 md:gap-3 lg:w-[calc(100%-115px)]">
{searchResult && ( {searchResult && (
<SubRow <SubRow
classes={props.titleclass + ' rounded'} classes={messageProps.titleclass + ' rounded'}
subclasses="switch-result pl-2 pb-2" subclasses="switch-result pl-2 pb-2"
onClick={clickSearchResult} 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, useOnClickOutside,
useConversation, useConversation,
useConversations, useConversations,
useLocalStorage,
} from '~/hooks'; } from '~/hooks';
import type { TDangerButtonProps } from '~/common'; import type { TDangerButtonProps } from '~/common';
import AutoScrollSwitch from './AutoScrollSwitch';
import DangerButton from './DangerButton'; import DangerButton from './DangerButton';
import store from '~/store'; import store from '~/store';
import useLocalStorage from '~/hooks/useLocalStorage';
export const ThemeSelector = ({ export const ThemeSelector = ({
theme, theme,
@ -175,6 +176,9 @@ function General() {
mutation={clearConvosMutation} mutation={clearConvosMutation}
/> />
</div> </div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<AutoScrollSwitch />
</div>
</div> </div>
</Tabs.Content> </Tabs.Content>
); );

View file

@ -24,7 +24,7 @@ import useTimeout from './useTimeout';
const AuthContext = createContext<TAuthContext | undefined>(undefined); const AuthContext = createContext<TAuthContext | undefined>(undefined);
const AuthContextProvider = ({ const AuthContextProvider = ({
// authConfig, authConfig,
children, children,
}: { }: {
authConfig?: TAuthConfig; authConfig?: TAuthConfig;
@ -98,6 +98,10 @@ const AuthContextProvider = ({
}, [setUserContext, doSetError, logoutUser]); }, [setUserContext, doSetError, logoutUser]);
const silentRefresh = useCallback(() => { const silentRefresh = useCallback(() => {
if (authConfig?.test) {
console.log('Test mode. Skipping silent refresh.');
return;
}
refreshToken.mutate(undefined, { refreshToken.mutate(undefined, {
onSuccess: (data: TLoginResponse) => { onSuccess: (data: TLoginResponse) => {
const { user, token } = data; const { user, token } = data;
@ -105,11 +109,17 @@ const AuthContextProvider = ({
setUserContext({ token, isAuthenticated: true, user }); setUserContext({ token, isAuthenticated: true, user });
} else { } else {
console.log('Token is not present. User is not authenticated.'); console.log('Token is not present. User is not authenticated.');
if (authConfig?.test) {
return;
}
navigate('/login'); navigate('/login');
} }
}, },
onError: (error) => { onError: (error) => {
console.log('refreshToken mutation error:', error); console.log('refreshToken mutation error:', error);
if (authConfig?.test) {
return;
}
navigate('/login'); 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_service_account: 'Create a Service Account',
com_endpoint_config_key_google_vertex_api_role: 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.', '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_store: 'Plugin store',
com_nav_plugin_search: 'Search plugins', com_nav_plugin_search: 'Search plugins',
com_nav_plugin_auth_error: 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_service_account: 'Crea un account di servizio',
com_endpoint_config_key_google_vertex_api_role: 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.', '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_store: 'Negozio dei plugin',
com_nav_plugin_search: 'Cerca plugin', com_nav_plugin_search: 'Cerca plugin',
com_nav_plugin_auth_error: com_nav_plugin_auth_error:

View file

@ -35,6 +35,25 @@ const showPopover = atom<boolean>({
default: false, 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 { export default {
abortScroll, abortScroll,
optionSettings, optionSettings,
@ -42,4 +61,5 @@ export default {
showAgentSettings, showAgentSettings,
showBingToneSetting, showBingToneSetting,
showPopover, showPopover,
autoScroll,
}; };

View file

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