import { useMemo, memo, type FC, useCallback, useEffect, useRef } from 'react'; import throttle from 'lodash/throttle'; import { ChevronDown } from 'lucide-react'; import { useRecoilValue } from 'recoil'; import { Spinner, useMediaQuery } from '@librechat/client'; import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized'; import type { TConversation } from 'librechat-data-provider'; import { useLocalize, TranslationKeys, useFavorites, useShowMarketplace } from '~/hooks'; import FavoritesList from '~/components/Nav/Favorites/FavoritesList'; import { groupConversationsByDate, cn } from '~/utils'; import Convo from './Convo'; import store from '~/store'; export type CellPosition = { columnIndex: number; rowIndex: number; }; export type MeasuredCellParent = { invalidateCellSizeAfterRender?: ((cell: CellPosition) => void) | undefined; recomputeGridSize?: ((cell: CellPosition) => void) | undefined; }; interface ConversationsProps { conversations: Array; moveToTop: () => void; toggleNav: () => void; containerRef: React.RefObject; loadMoreConversations: () => void; isLoading: boolean; isSearchLoading: boolean; isChatsExpanded: boolean; setIsChatsExpanded: (expanded: boolean) => void; } interface MeasuredRowProps { cache: CellMeasurerCache; rowKey: string; parent: MeasuredCellParent; index: number; style: React.CSSProperties; children: React.ReactNode; } /** Reusable wrapper for virtualized row measurement */ const MeasuredRow: FC = memo( ({ cache, rowKey, parent, index, style, children }) => ( {({ registerChild }) => (
} style={style}> {children}
)}
), ); MeasuredRow.displayName = 'MeasuredRow'; const LoadingSpinner = memo(() => { const localize = useLocalize(); return (
{localize('com_ui_loading')}
); }); LoadingSpinner.displayName = 'LoadingSpinner'; interface ChatsHeaderProps { isExpanded: boolean; onToggle: () => void; } /** Collapsible header for the Chats section */ const ChatsHeader: FC = memo(({ isExpanded, onToggle }) => { const localize = useLocalize(); return ( ); }); ChatsHeader.displayName = 'ChatsHeader'; const DateLabel: FC<{ groupName: string; isFirst?: boolean }> = memo(({ groupName, isFirst }) => { const localize = useLocalize(); return (

{localize(groupName as TranslationKeys) || groupName}

); }); DateLabel.displayName = 'DateLabel'; type FlattenedItem = | { type: 'favorites' } | { type: 'chats-header' } | { type: 'header'; groupName: string } | { type: 'convo'; convo: TConversation } | { type: 'loading' }; const MemoizedConvo = memo( ({ conversation, retainView, toggleNav, }: { conversation: TConversation; retainView: () => void; toggleNav: () => void; }) => { return ; }, (prevProps, nextProps) => { return ( prevProps.conversation.conversationId === nextProps.conversation.conversationId && prevProps.conversation.title === nextProps.conversation.title && prevProps.conversation.endpoint === nextProps.conversation.endpoint ); }, ); const Conversations: FC = ({ conversations: rawConversations, moveToTop, toggleNav, containerRef, loadMoreConversations, isLoading, isSearchLoading, isChatsExpanded, setIsChatsExpanded, }) => { const localize = useLocalize(); const search = useRecoilValue(store.search); const { favorites, isLoading: isFavoritesLoading } = useFavorites(); const isSmallScreen = useMediaQuery('(max-width: 768px)'); const convoHeight = isSmallScreen ? 44 : 34; const showAgentMarketplace = useShowMarketplace(); // Determine if FavoritesList will render content const shouldShowFavorites = !search.query && (isFavoritesLoading || favorites.length > 0 || showAgentMarketplace); const filteredConversations = useMemo( () => rawConversations.filter(Boolean) as TConversation[], [rawConversations], ); const groupedConversations = useMemo( () => groupConversationsByDate(filteredConversations), [filteredConversations], ); const flattenedItems = useMemo(() => { const items: FlattenedItem[] = []; // Only include favorites row if FavoritesList will render content if (shouldShowFavorites) { items.push({ type: 'favorites' }); } items.push({ type: 'chats-header' }); if (isChatsExpanded) { groupedConversations.forEach(([groupName, convos]) => { items.push({ type: 'header', groupName }); items.push(...convos.map((convo) => ({ type: 'convo' as const, convo }))); }); if (isLoading) { items.push({ type: 'loading' } as any); } } return items; }, [groupedConversations, isLoading, isChatsExpanded, shouldShowFavorites]); // Store flattenedItems in a ref for keyMapper to access without recreating cache const flattenedItemsRef = useRef(flattenedItems); flattenedItemsRef.current = flattenedItems; // Create a stable cache that doesn't depend on flattenedItems const cache = useMemo( () => new CellMeasurerCache({ fixedWidth: true, defaultHeight: convoHeight, keyMapper: (index) => { const item = flattenedItemsRef.current[index]; if (!item) { return `unknown-${index}`; } if (item.type === 'favorites') { return 'favorites'; } if (item.type === 'chats-header') { return 'chats-header'; } if (item.type === 'header') { return `header-${item.groupName}`; } if (item.type === 'convo') { return `convo-${item.convo.conversationId}`; } if (item.type === 'loading') { return 'loading'; } return `unknown-${index}`; }, }), [convoHeight], ); // Debounced function to clear cache and recompute heights const clearFavoritesCache = useCallback(() => { if (cache) { cache.clear(0, 0); if (containerRef.current && 'recomputeRowHeights' in containerRef.current) { containerRef.current.recomputeRowHeights(0); } } }, [cache, containerRef]); // Clear cache when favorites change useEffect(() => { const frameId = requestAnimationFrame(() => { clearFavoritesCache(); }); return () => cancelAnimationFrame(frameId); }, [favorites.length, isFavoritesLoading, clearFavoritesCache]); const rowRenderer = useCallback( ({ index, key, parent, style }) => { const item = flattenedItems[index]; const rowProps = { cache, rowKey: key, parent, index, style }; if (item.type === 'loading') { return ( ); } if (item.type === 'favorites') { return ( ); } if (item.type === 'chats-header') { return ( setIsChatsExpanded(!isChatsExpanded)} /> ); } if (item.type === 'header') { // First date header index depends on whether favorites row is included // With favorites: [favorites, chats-header, first-header] → index 2 // Without favorites: [chats-header, first-header] → index 1 const firstHeaderIndex = shouldShowFavorites ? 2 : 1; return ( ); } if (item.type === 'convo') { return ( ); } return null; }, [ cache, flattenedItems, moveToTop, toggleNav, clearFavoritesCache, isSmallScreen, isChatsExpanded, setIsChatsExpanded, shouldShowFavorites, ], ); const getRowHeight = useCallback( ({ index }: { index: number }) => cache.getHeight(index, 0), [cache], ); const throttledLoadMore = useMemo( () => throttle(loadMoreConversations, 300), [loadMoreConversations], ); const handleRowsRendered = useCallback( ({ stopIndex }: { stopIndex: number }) => { if (stopIndex >= flattenedItems.length - 8) { throttledLoadMore(); } }, [flattenedItems.length, throttledLoadMore], ); return (
{isSearchLoading ? (
{localize('com_ui_loading')}
) : (
{({ width, height }) => ( )}
)}
); }; export default memo(Conversations);