diff --git a/.github/workflows/frontend-review.yml b/.github/workflows/frontend-review.yml index a26324fb04..558a36779a 100644 --- a/.github/workflows/frontend-review.yml +++ b/.github/workflows/frontend-review.yml @@ -34,4 +34,5 @@ jobs: run: npm run frontend:ci - name: Run unit tests - run: cd client && npm run test:ci \ No newline at end of file + run: npm run test:ci --verbose + working-directory: client \ No newline at end of file diff --git a/client/package.json b/client/package.json index b027ebe0dc..e9be5f0614 100644 --- a/client/package.json +++ b/client/package.json @@ -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" diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 030095fcdf..6f96322258 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -177,6 +177,7 @@ export type TUserContext = { export type TAuthConfig = { loginRedirect: string; + test?: boolean; }; export type IconProps = Pick & diff --git a/client/src/components/Messages/Message.tsx b/client/src/components/Messages/Message.tsx index 8f6f50e355..235ff015b7 100644 --- a/client/src/components/Messages/Message.tsx +++ b/client/src/components/Messages/Message.tsx @@ -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 ( <> -
+
{typeof icon === 'string' && /[^\\x00-\\x7F]+/.test(icon as string) ? ( @@ -153,7 +164,7 @@ export default function Message({
{searchResult && ( diff --git a/client/src/components/Nav/SettingsTabs/AutoScrollSwitch.spec.tsx b/client/src/components/Nav/SettingsTabs/AutoScrollSwitch.spec.tsx new file mode 100644 index 0000000000..a4a4d69533 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/AutoScrollSwitch.spec.tsx @@ -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 | ((value: boolean) => void) | undefined; + + beforeEach(() => { + mockSetAutoScroll = jest.fn(); + }); + + it('renders correctly', () => { + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('autoScroll')).toBeInTheDocument(); + }); + + it('calls onCheckedChange when the switch is toggled', () => { + const { getByTestId } = render( + + + , + ); + const switchElement = getByTestId('autoScroll'); + fireEvent.click(switchElement); + + expect(mockSetAutoScroll).toHaveBeenCalledWith(true); + }); +}); diff --git a/client/src/components/Nav/SettingsTabs/AutoScrollSwitch.tsx b/client/src/components/Nav/SettingsTabs/AutoScrollSwitch.tsx new file mode 100644 index 0000000000..254c4d8e17 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/AutoScrollSwitch.tsx @@ -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(store.autoScroll); + const localize = useLocalize(); + + const handleCheckedChange = (value: boolean) => { + setAutoScroll(value); + if (onCheckedChange) { + onCheckedChange(value); + } + }; + + return ( +
+
{localize('com_nav_auto_scroll')}
+ +
+ ); +} diff --git a/client/src/components/Nav/SettingsTabs/General.tsx b/client/src/components/Nav/SettingsTabs/General.tsx index 09361ad4e0..36175fd876 100644 --- a/client/src/components/Nav/SettingsTabs/General.tsx +++ b/client/src/components/Nav/SettingsTabs/General.tsx @@ -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} />
+
+ +
); diff --git a/client/src/hooks/AuthContext.tsx b/client/src/hooks/AuthContext.tsx index 9bbcd83db5..7e8aff8a56 100644 --- a/client/src/hooks/AuthContext.tsx +++ b/client/src/hooks/AuthContext.tsx @@ -24,7 +24,7 @@ import useTimeout from './useTimeout'; const AuthContext = createContext(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'); }, }); diff --git a/client/src/localization/languages/Eng.tsx b/client/src/localization/languages/Eng.tsx index e27db00958..5e0f755bbb 100644 --- a/client/src/localization/languages/Eng.tsx +++ b/client/src/localization/languages/Eng.tsx @@ -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: diff --git a/client/src/localization/languages/It.tsx b/client/src/localization/languages/It.tsx index 076d13477d..9d777c8805 100644 --- a/client/src/localization/languages/It.tsx +++ b/client/src/localization/languages/It.tsx @@ -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: diff --git a/client/src/store/settings.ts b/client/src/store/settings.ts index 139be45cbb..098296b92b 100644 --- a/client/src/store/settings.ts +++ b/client/src/store/settings.ts @@ -35,6 +35,25 @@ const showPopover = atom({ default: false, }); +const autoScroll = atom({ + 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, }; diff --git a/client/test/layout-test-utils.tsx b/client/test/layout-test-utils.tsx index f191c86974..6be88e2b29 100644 --- a/client/test/layout-test-utils.tsx +++ b/client/test/layout-test-utils.tsx @@ -17,6 +17,7 @@ function renderWithProvidersWrapper(ui, { ...options } = {}) { {children}