Skip to content

Commit a7f4bf3

Browse files
feat: auto-scroll to the bottom of the conversation (danny-avila#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 <[email protected]>
1 parent 8020c57 commit a7f4bf3

File tree

12 files changed

+158
-37
lines changed

12 files changed

+158
-37
lines changed

.github/workflows/frontend-review.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,5 @@ jobs:
3434
run: npm run frontend:ci
3535

3636
- name: Run unit tests
37-
run: cd client && npm run test:ci
37+
run: npm run test:ci --verbose
38+
working-directory: client

client/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
"build:ci": "cross-env NODE_ENV=development vite build --mode ci",
99
"dev": "cross-env NODE_ENV=development vite",
1010
"preview-prod": "cross-env NODE_ENV=development vite preview",
11-
"test": "cross-env NODE_ENV=test jest --watch",
12-
"test:ci": "cross-env NODE_ENV=test jest --ci",
11+
"test": "cross-env NODE_ENV=development jest --watch",
12+
"test:ci": "cross-env NODE_ENV=development jest --ci",
1313
"b:test": "NODE_ENV=test bunx jest --watch",
1414
"b:build": "NODE_ENV=production bun --bun vite build",
1515
"b:dev": "NODE_ENV=development bunx vite"

client/src/common/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ export type TUserContext = {
177177

178178
export type TAuthConfig = {
179179
loginRedirect: string;
180+
test?: boolean;
180181
};
181182

182183
export type IconProps = Pick<TMessage, 'isCreatedByUser' | 'model' | 'error'> &

client/src/components/Messages/Message.tsx

Lines changed: 43 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable react-hooks/exhaustive-deps */
22
import { useGetConversationByIdQuery } from 'librechat-data-provider';
33
import { useEffect } from 'react';
4-
import { useSetRecoilState, useRecoilState } from 'recoil';
4+
import { useSetRecoilState, useRecoilState, useRecoilValue } from 'recoil';
55
import copy from 'copy-to-clipboard';
66
import { SubRow, Plugin, MessageContent } from './Content';
77
// eslint-disable-next-line import/no-cycle
@@ -13,21 +13,27 @@ import { useMessageHandler, useConversation } from '~/hooks';
1313
import type { TMessageProps } from '~/common';
1414
import { cn } from '~/utils';
1515
import store from '~/store';
16+
import { useParams } from 'react-router-dom';
17+
18+
export default function Message(props: TMessageProps) {
19+
const {
20+
conversation,
21+
message,
22+
scrollToBottom,
23+
currentEditId,
24+
setCurrentEditId,
25+
siblingIdx,
26+
siblingCount,
27+
setSiblingIdx,
28+
} = props;
1629

17-
export default function Message({
18-
conversation,
19-
message,
20-
scrollToBottom,
21-
currentEditId,
22-
setCurrentEditId,
23-
siblingIdx,
24-
siblingCount,
25-
setSiblingIdx,
26-
}: TMessageProps) {
2730
const setLatestMessage = useSetRecoilState(store.latestMessage);
2831
const [abortScroll, setAbortScroll] = useRecoilState(store.abortScroll);
2932
const { isSubmitting, ask, regenerate, handleContinue } = useMessageHandler();
3033
const { switchToConversation } = useConversation();
34+
const { conversationId } = useParams();
35+
const isSearching = useRecoilValue(store.isSearching);
36+
3137
const {
3238
text,
3339
children,
@@ -37,32 +43,34 @@ export default function Message({
3743
error,
3844
unfinished,
3945
} = message ?? {};
46+
4047
const isLast = !children?.length;
41-
const edit = messageId == currentEditId;
48+
const edit = messageId === currentEditId;
4249
const getConversationQuery = useGetConversationByIdQuery(message?.conversationId ?? '', {
4350
enabled: false,
4451
});
45-
const blinker = message?.submitting && isSubmitting;
4652

47-
// debugging
48-
// useEffect(() => {
49-
// console.log('isSubmitting:', isSubmitting);
50-
// console.log('unfinished:', unfinished);
51-
// }, [isSubmitting, unfinished]);
53+
const autoScroll = useRecoilValue(store.autoScroll);
5254

5355
useEffect(() => {
54-
if (blinker && scrollToBottom && !abortScroll) {
56+
if (isSubmitting && scrollToBottom && !abortScroll) {
5557
scrollToBottom();
5658
}
57-
}, [isSubmitting, blinker, text, scrollToBottom]);
59+
}, [isSubmitting, text, scrollToBottom, abortScroll]);
60+
61+
useEffect(() => {
62+
if (scrollToBottom && autoScroll && !isSearching && conversationId !== 'new') {
63+
scrollToBottom();
64+
}
65+
}, [autoScroll, conversationId, scrollToBottom, isSearching]);
5866

5967
useEffect(() => {
6068
if (!message) {
6169
return;
6270
} else if (isLast) {
6371
setLatestMessage({ ...message });
6472
}
65-
}, [isLast, message]);
73+
}, [isLast, message, setLatestMessage]);
6674

6775
if (!message) {
6876
return null;
@@ -72,7 +80,7 @@ export default function Message({
7280
setCurrentEditId && setCurrentEditId(cancel ? -1 : messageId);
7381

7482
const handleScroll = () => {
75-
if (blinker) {
83+
if (isSubmitting) {
7684
setAbortScroll(true);
7785
} else {
7886
setAbortScroll(false);
@@ -85,7 +93,7 @@ export default function Message({
8593
? 'bg-white dark:bg-gray-800 dark:text-gray-20'
8694
: 'bg-gray-50 dark:bg-gray-1000 dark:text-gray-70';
8795

88-
const props = {
96+
const messageProps = {
8997
className: cn(commonClasses, uniqueClasses),
9098
titleclass: '',
9199
};
@@ -98,8 +106,8 @@ export default function Message({
98106
});
99107

100108
if (message?.bg && searchResult) {
101-
props.className = message?.bg?.split('hover')[0];
102-
props.titleclass = message?.bg?.split(props.className)[1] + ' cursor-pointer';
109+
messageProps.className = message?.bg?.split('hover')[0];
110+
messageProps.titleclass = message?.bg?.split(messageProps.className)[1] + ' cursor-pointer';
103111
}
104112

105113
const regenerateMessage = () => {
@@ -124,17 +132,20 @@ export default function Message({
124132
if (!message) {
125133
return;
126134
}
127-
getConversationQuery.refetch({ queryKey: [message?.conversationId] }).then((response) => {
128-
console.log('getConversationQuery response.data:', response.data);
129-
if (response.data) {
130-
switchToConversation(response.data);
131-
}
135+
const response = await getConversationQuery.refetch({
136+
queryKey: [message?.conversationId],
132137
});
138+
139+
console.log('getConversationQuery response.data:', response.data);
140+
141+
if (response.data) {
142+
switchToConversation(response.data);
143+
}
133144
};
134145

135146
return (
136147
<>
137-
<div {...props} onWheel={handleScroll} onTouchMove={handleScroll}>
148+
<div {...messageProps} onWheel={handleScroll} onTouchMove={handleScroll}>
138149
<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">
139150
<div className="relative flex h-[40px] w-[40px] flex-col items-end text-right text-xs md:text-sm">
140151
{typeof icon === 'string' && /[^\\x00-\\x7F]+/.test(icon as string) ? (
@@ -153,7 +164,7 @@ export default function Message({
153164
<div className="relative flex w-[calc(100%-50px)] flex-col gap-1 md:gap-3 lg:w-[calc(100%-115px)]">
154165
{searchResult && (
155166
<SubRow
156-
classes={props.titleclass + ' rounded'}
167+
classes={messageProps.titleclass + ' rounded'}
157168
subclasses="switch-result pl-2 pb-2"
158169
onClick={clickSearchResult}
159170
>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import React from 'react';
2+
import '@testing-library/jest-dom/extend-expect';
3+
import { render, fireEvent } from 'test/layout-test-utils';
4+
import AutoScrollSwitch from './AutoScrollSwitch';
5+
import { RecoilRoot } from 'recoil';
6+
7+
describe('AutoScrollSwitch', () => {
8+
/**
9+
* Mock function to set the auto-scroll state.
10+
*/
11+
let mockSetAutoScroll: jest.Mock<void, [boolean]> | ((value: boolean) => void) | undefined;
12+
13+
beforeEach(() => {
14+
mockSetAutoScroll = jest.fn();
15+
});
16+
17+
it('renders correctly', () => {
18+
const { getByTestId } = render(
19+
<RecoilRoot>
20+
<AutoScrollSwitch />
21+
</RecoilRoot>,
22+
);
23+
24+
expect(getByTestId('autoScroll')).toBeInTheDocument();
25+
});
26+
27+
it('calls onCheckedChange when the switch is toggled', () => {
28+
const { getByTestId } = render(
29+
<RecoilRoot>
30+
<AutoScrollSwitch onCheckedChange={mockSetAutoScroll} />
31+
</RecoilRoot>,
32+
);
33+
const switchElement = getByTestId('autoScroll');
34+
fireEvent.click(switchElement);
35+
36+
expect(mockSetAutoScroll).toHaveBeenCalledWith(true);
37+
});
38+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useRecoilState } from 'recoil';
2+
import { Switch } from '~/components/ui';
3+
import { useLocalize } from '~/hooks';
4+
import store from '~/store';
5+
6+
export default function AutoScrollSwitch({
7+
onCheckedChange,
8+
}: {
9+
onCheckedChange?: (value: boolean) => void;
10+
}) {
11+
const [autoScroll, setAutoScroll] = useRecoilState<boolean>(store.autoScroll);
12+
const localize = useLocalize();
13+
14+
const handleCheckedChange = (value: boolean) => {
15+
setAutoScroll(value);
16+
if (onCheckedChange) {
17+
onCheckedChange(value);
18+
}
19+
};
20+
21+
return (
22+
<div className="flex items-center justify-between">
23+
<div>{localize('com_nav_auto_scroll')}</div>
24+
<Switch
25+
id="autoScroll"
26+
checked={autoScroll}
27+
onCheckedChange={handleCheckedChange}
28+
className="ml-4 mt-2"
29+
data-testid="autoScroll"
30+
/>
31+
</div>
32+
);
33+
}

client/src/components/Nav/SettingsTabs/General.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import {
88
useOnClickOutside,
99
useConversation,
1010
useConversations,
11+
useLocalStorage,
1112
} from '~/hooks';
1213
import type { TDangerButtonProps } from '~/common';
14+
import AutoScrollSwitch from './AutoScrollSwitch';
1315
import DangerButton from './DangerButton';
1416
import store from '~/store';
15-
import useLocalStorage from '~/hooks/useLocalStorage';
1617

1718
export const ThemeSelector = ({
1819
theme,
@@ -175,6 +176,9 @@ function General() {
175176
mutation={clearConvosMutation}
176177
/>
177178
</div>
179+
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
180+
<AutoScrollSwitch />
181+
</div>
178182
</div>
179183
</Tabs.Content>
180184
);

client/src/hooks/AuthContext.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import useTimeout from './useTimeout';
2424
const AuthContext = createContext<TAuthContext | undefined>(undefined);
2525

2626
const AuthContextProvider = ({
27-
// authConfig,
27+
authConfig,
2828
children,
2929
}: {
3030
authConfig?: TAuthConfig;
@@ -98,18 +98,28 @@ const AuthContextProvider = ({
9898
}, [setUserContext, doSetError, logoutUser]);
9999

100100
const silentRefresh = useCallback(() => {
101+
if (authConfig?.test) {
102+
console.log('Test mode. Skipping silent refresh.');
103+
return;
104+
}
101105
refreshToken.mutate(undefined, {
102106
onSuccess: (data: TLoginResponse) => {
103107
const { user, token } = data;
104108
if (token) {
105109
setUserContext({ token, isAuthenticated: true, user });
106110
} else {
107111
console.log('Token is not present. User is not authenticated.');
112+
if (authConfig?.test) {
113+
return;
114+
}
108115
navigate('/login');
109116
}
110117
},
111118
onError: (error) => {
112119
console.log('refreshToken mutation error:', error);
120+
if (authConfig?.test) {
121+
return;
122+
}
113123
navigate('/login');
114124
},
115125
});

client/src/localization/languages/Eng.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ export default {
226226
com_endpoint_config_key_google_service_account: 'Create a Service Account',
227227
com_endpoint_config_key_google_vertex_api_role:
228228
'Make sure to click \'Create and Continue\' to give at least the \'Vertex AI User\' role. Lastly, create a JSON key to import here.',
229+
com_nav_auto_scroll: 'Auto-scroll to Newest on Open',
229230
com_nav_plugin_store: 'Plugin store',
230231
com_nav_plugin_search: 'Search plugins',
231232
com_nav_plugin_auth_error:

client/src/localization/languages/It.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ export default {
226226
com_endpoint_config_key_google_service_account: 'Crea un account di servizio',
227227
com_endpoint_config_key_google_vertex_api_role:
228228
'Assicurati di fare clic su \'Crea e continua\' per dare almeno il ruolo \'Utente Vertex AI\'. Infine, crea una chiave JSON da importare qui.',
229+
com_nav_auto_scroll: 'Scorrimento automatico',
229230
com_nav_plugin_store: 'Negozio dei plugin',
230231
com_nav_plugin_search: 'Cerca plugin',
231232
com_nav_plugin_auth_error:

0 commit comments

Comments
 (0)