mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 00:40:14 +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
3
.github/workflows/frontend-review.yml
vendored
3
.github/workflows/frontend-review.yml
vendored
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ export type TUserContext = {
|
|||
|
||||
export type TAuthConfig = {
|
||||
loginRedirect: string;
|
||||
test?: boolean;
|
||||
};
|
||||
|
||||
export type IconProps = Pick<TMessage, 'isCreatedByUser' | 'model' | 'error'> &
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ function renderWithProvidersWrapper(ui, { ...options } = {}) {
|
|||
<AuthContextProvider
|
||||
authConfig={{
|
||||
loginRedirect: '',
|
||||
test: true,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue