From 5460466519f725d1c86579269ccf46b7439ccd46 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 9 Feb 2025 01:19:51 +0100 Subject: [PATCH 01/15] =?UTF-8?q?=E2=9C=A8=20feat:=20improve=20Nav/Convers?= =?UTF-8?q?ations/Convo/NewChat=20component=20performance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/package.json | 1 + .../Conversations/Conversations.tsx | 178 ++++++-- client/src/components/Conversations/Convo.tsx | 238 ++++++----- client/src/components/Nav/Nav.tsx | 396 +++++++++--------- client/src/components/Nav/NewChat.tsx | 61 +-- package-lock.json | 356 ++-------------- 6 files changed, 559 insertions(+), 671 deletions(-) diff --git a/client/package.json b/client/package.json index df85c2521c2..7cbb6cd88a3 100644 --- a/client/package.json +++ b/client/package.json @@ -118,6 +118,7 @@ "@testing-library/user-event": "^14.4.3", "@types/jest": "^29.5.14", "@types/js-cookie": "^3.0.6", + "@types/lodash": "^4.17.15", "@types/node": "^20.3.0", "@types/react": "^18.2.11", "@types/react-dom": "^18.2.4", diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index 67d79c704cd..149dac4f47c 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -1,67 +1,161 @@ -import { useMemo, memo } from 'react'; +import { useMemo, memo, type FC, useCallback } from 'react'; +import { throttle } from 'lodash'; import { parseISO, isToday } from 'date-fns'; +import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized'; import { TConversation } from 'librechat-data-provider'; import { useLocalize, TranslationKeys } from '~/hooks'; import { groupConversationsByDate } from '~/utils'; +import { Spinner } from '~/components/svg'; import Convo from './Convo'; -const Conversations = ({ - conversations, - moveToTop, - toggleNav, -}: { +interface ConversationsProps { conversations: Array; moveToTop: () => void; toggleNav: () => void; -}) => { + containerRef: React.RefObject; + loadMoreConversations: () => void; + isFetchingNextPage: boolean; +} + +const LoadingSpinner = memo(() => ( + +)); + +const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => { const localize = useLocalize(); + return ( +
+ {localize(groupName as TranslationKeys) || groupName} +
+ ); +}); +DateLabel.displayName = 'DateLabel'; + +type FlattenedItem = + | { type: 'header'; groupName: string } + | { type: 'convo'; convo: TConversation }; + +const Conversations: FC = ({ + conversations: rawConversations, + moveToTop, + toggleNav, + containerRef, + loadMoreConversations, + isFetchingNextPage, +}) => { + const filteredConversations = useMemo( + () => rawConversations.filter(Boolean) as TConversation[], + [rawConversations], + ); + const groupedConversations = useMemo( - () => groupConversationsByDate(conversations), - [conversations], + () => groupConversationsByDate(filteredConversations), + [filteredConversations], ); + const firstTodayConvoId = useMemo( () => - conversations.find((convo) => convo && convo.updatedAt && isToday(parseISO(convo.updatedAt))) - ?.conversationId, - [conversations], + filteredConversations.find((convo) => convo.updatedAt && isToday(parseISO(convo.updatedAt))) + ?.conversationId ?? undefined, + [filteredConversations], ); - return ( -
-
- - {groupedConversations.map(([groupName, convos]) => ( -
-
- {localize(groupName as TranslationKeys) || groupName} -
- {convos.map((convo, i) => ( + const flattenedItems = useMemo(() => { + const items: FlattenedItem[] = []; + groupedConversations.forEach(([groupName, convos]) => { + items.push({ type: 'header', groupName }); + items.push(...convos.map((convo) => ({ type: 'convo' as const, convo }))); + }); + return items; + }, [groupedConversations]); + + const cache = useMemo( + () => + new CellMeasurerCache({ + fixedWidth: true, + defaultHeight: 34, + keyMapper: (index) => { + const item = flattenedItems[index]; + return item.type === 'header' ? `header-${index}` : `convo-${item.convo.conversationId}`; + }, + }), + [flattenedItems], + ); + + const rowRenderer = useCallback( + ({ index, key, parent, style }) => { + const item = flattenedItems[index]; + return ( + + {({ registerChild }) => ( +
+ {item.type === 'header' ? ( + + ) : ( - ))} -
+ )}
- ))} - + )} + + ); + }, + [cache, flattenedItems, firstTodayConvoId, moveToTop, toggleNav], + ); + + const getRowHeight = useCallback( + ({ index }: { index: number }) => cache.getHeight(index, 0), + [cache], + ); + + // Throttle the loadMoreConversations call so it's not triggered too frequently. + const throttledLoadMore = useMemo( + () => throttle(loadMoreConversations, 300), + [loadMoreConversations], + ); + + const handleRowsRendered = useCallback( + ({ stopIndex }: { stopIndex: number }) => { + // Trigger early when user scrolls within 2 items of the end. + if (stopIndex >= flattenedItems.length - 2) { + throttledLoadMore(); + } + }, + [flattenedItems.length, throttledLoadMore], + ); + + return ( +
+
+ + {({ width, height }) => ( + } + width={width} + height={height} + deferredMeasurementCache={cache} + rowCount={flattenedItems.length} + rowHeight={getRowHeight} + rowRenderer={rowRenderer} + overscanRowCount={10} + className="outline-none" + style={{ outline: 'none' }} + role="list" + aria-label="Conversations" + onRowsRendered={handleRowsRendered} + /> + )} +
+ {isFetchingNextPage && ( +
+ +
+ )}
); }; diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index b0e6e066360..88af77cdc59 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -17,12 +17,12 @@ import store from '~/store'; type KeyEvent = KeyboardEvent; -type ConversationProps = { +interface ConversationProps { conversation: TConversation; retainView: () => void; toggleNav: () => void; isLatestConvo: boolean; -}; +} export default function Conversation({ conversation, @@ -30,167 +30,196 @@ export default function Conversation({ toggleNav, isLatestConvo, }: ConversationProps) { + const { conversationId, title = '' } = conversation; + const params = useParams(); - const currentConvoId = useMemo(() => params.conversationId, [params.conversationId]); + const currentConvoId = params.conversationId; const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? ''); const activeConvos = useRecoilValue(store.allConversationsSelector); const { data: endpointsConfig } = useGetEndpointsQuery(); const { navigateWithLastTools } = useNavigateToConvo(); const { showToast } = useToastContext(); - const { conversationId, title } = conversation; - const inputRef = useRef(null); - const [titleInput, setTitleInput] = useState(title); - const [renaming, setRenaming] = useState(false); - const [isPopoverActive, setIsPopoverActive] = useState(false); - const isSmallScreen = useMediaQuery('(max-width: 768px)'); const localize = useLocalize(); + const isSmallScreen = useMediaQuery('(max-width: 768px)'); - const clickHandler = async (event: MouseEvent) => { - if (event.button === 0 && (event.ctrlKey || event.metaKey)) { - toggleNav(); - return; - } - - event.preventDefault(); + const [titleInput, setTitleInput] = useState(title || ''); + const [renaming, setRenaming] = useState(false); + const [isPopoverActive, setIsPopoverActive] = useState(false); - if (currentConvoId === conversationId || isPopoverActive) { - return; - } + const inputRef = useRef(null); + const previousTitle = useRef(title); - toggleNav(); + const isActiveConvo = useMemo( + () => + currentConvoId === conversationId || + (isLatestConvo && + currentConvoId === 'new' && + activeConvos[0] != null && + activeConvos[0] !== 'new'), + [currentConvoId, conversationId, isLatestConvo, activeConvos], + ); - // set document title - if (typeof title === 'string' && title.length > 0) { - document.title = title; + useEffect(() => { + if (title !== previousTitle.current) { + setTitleInput(title as string); + previousTitle.current = title; } - /* Note: Latest Message should not be reset if existing convo */ - navigateWithLastTools( - conversation, - !(conversationId ?? '') || conversationId === Constants.NEW_CONVO, - ); - }; - - const renameHandler = useCallback(() => { - setIsPopoverActive(false); - setTitleInput(title); - setRenaming(true); }, [title]); useEffect(() => { if (renaming && inputRef.current) { inputRef.current.focus(); + inputRef.current.select(); } }, [renaming]); - const onRename = useCallback( - (e: MouseEvent | FocusEvent | KeyEvent) => { - e.preventDefault(); - setRenaming(false); - if (titleInput === title) { + const handleClick = useCallback( + async (event: MouseEvent) => { + if (event.button === 0 && (event.ctrlKey || event.metaKey)) { + toggleNav(); return; } - if (typeof conversationId !== 'string' || conversationId === '') { + + event.preventDefault(); + + if (currentConvoId === conversationId || isPopoverActive) { return; } - updateConvoMutation.mutate( - { conversationId, title: titleInput ?? '' }, - { - onError: () => { - setTitleInput(title); - showToast({ - message: 'Failed to rename conversation', - severity: NotificationSeverity.ERROR, - showIcon: true, - }); - }, - }, + toggleNav(); + + if (typeof title === 'string' && title.length > 0) { + document.title = title; + } + + navigateWithLastTools( + conversation, + !(conversationId ?? '') || conversationId === Constants.NEW_CONVO, ); }, - [title, titleInput, conversationId, showToast, updateConvoMutation], + [ + currentConvoId, + conversationId, + isPopoverActive, + toggleNav, + title, + conversation, + navigateWithLastTools, + ], + ); + + const handleRename = useCallback(() => { + setIsPopoverActive(false); + setTitleInput(title as string); + setRenaming(true); + }, [title]); + + const handleRenameSubmit = useCallback( + async (e: MouseEvent | FocusEvent | KeyEvent) => { + e.preventDefault(); + + if (!conversationId || titleInput === title) { + setRenaming(false); + return; + } + + try { + await updateConvoMutation.mutateAsync({ + conversationId, + title: titleInput.trim() || localize('com_ui_untitled'), + }); + setRenaming(false); + } catch (error) { + setTitleInput(title as string); + showToast({ + message: localize('com_ui_rename_failed'), + severity: NotificationSeverity.ERROR, + showIcon: true, + }); + setRenaming(false); + } + }, + [conversationId, title, titleInput, updateConvoMutation, showToast, localize], ); const handleKeyDown = useCallback( (e: KeyEvent) => { - if (e.key === 'Escape') { - setTitleInput(title); - setRenaming(false); - } else if (e.key === 'Enter') { - onRename(e); + switch (e.key) { + case 'Escape': + setTitleInput(title as string); + setRenaming(false); + break; + case 'Enter': + handleRenameSubmit(e); + break; } }, - [title, onRename], + [title, handleRenameSubmit], ); - const cancelRename = useCallback( + const handleCancelRename = useCallback( (e: MouseEvent) => { e.preventDefault(); - setTitleInput(title); + setTitleInput(title as string); setRenaming(false); }, [title], ); - const isActiveConvo: boolean = useMemo( - () => - currentConvoId === conversationId || - (isLatestConvo && - currentConvoId === 'new' && - activeConvos[0] != null && - activeConvos[0] !== 'new'), - [currentConvoId, conversationId, isLatestConvo, activeConvos], - ); - return (
{renaming ? ( -
+
setTitleInput(e.target.value)} onKeyDown={handleKeyDown} - aria-label={`${localize('com_ui_rename')} ${localize('com_ui_chat')}`} + onBlur={handleRenameSubmit} + maxLength={100} + aria-label={localize('com_ui_new_conversation_title')} /> -
+
) : ( { e.preventDefault(); e.stopPropagation(); - setTitleInput(title); - setRenaming(true); + handleRename(); }} > - {title} + {title || localize('com_ui_untitled')}
- {isActiveConvo ? ( -
- ) : ( -
- )} +