import React, { useMemo, useEffect, useCallback, useRef } from 'react'; import { AutoSizer, List as VirtualList, WindowScroller } from 'react-virtualized'; import { throttle } from 'lodash'; import { Spinner } from '@librechat/client'; import { PermissionBits } from 'librechat-data-provider'; import type t from 'librechat-data-provider'; import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents'; import { useAgentCategories, useLocalize } from '~/hooks'; import { useHasData } from './SmartLoader'; import ErrorDisplay from './ErrorDisplay'; import AgentCard from './AgentCard'; import { cn } from '~/utils'; interface VirtualizedAgentGridProps { category: string; searchQuery: string; onSelectAgent: (agent: t.Agent) => void; scrollElement?: HTMLElement | null; } // Constants for layout calculations const CARD_HEIGHT = 160; // h-40 in pixels const GAP_SIZE = 24; // gap-6 in pixels const ROW_HEIGHT = CARD_HEIGHT + GAP_SIZE; const CARDS_PER_ROW_MOBILE = 1; const CARDS_PER_ROW_DESKTOP = 2; const OVERSCAN_ROW_COUNT = 3; /** * Virtualized grid component for displaying agent cards with high performance */ const VirtualizedAgentGrid: React.FC = ({ category, searchQuery, onSelectAgent, scrollElement, }) => { const localize = useLocalize(); const listRef = useRef(null); const { categories } = useAgentCategories(); // Build query parameters const queryParams = useMemo(() => { const params: { requiredPermission: number; category?: string; search?: string; limit: number; promoted?: 0 | 1; } = { requiredPermission: PermissionBits.VIEW, // Align with AgentGrid to eliminate API mismatch as a factor limit: 6, }; if (searchQuery) { params.search = searchQuery; if (category !== 'all' && category !== 'promoted') { params.category = category; } } else { if (category === 'promoted') { params.promoted = 1; } else if (category !== 'all') { params.category = category; } } return params; }, [category, searchQuery]); // Use infinite query const { data, isLoading, error, isFetching, fetchNextPage, hasNextPage, refetch, isFetchingNextPage, } = useMarketplaceAgentsInfiniteQuery(queryParams); // Flatten pages into single array const currentAgents = useMemo(() => { if (!data?.pages) return []; return data.pages.flatMap((page) => page.data || []); }, [data?.pages]); const hasData = useHasData(data?.pages?.[0]); // Direct scroll handling for virtualized component to avoid hook conflicts useEffect(() => { if (!scrollElement) return; const throttledScrollHandler = throttle(() => { const { scrollTop, scrollHeight, clientHeight } = scrollElement; const scrollPosition = (scrollTop + clientHeight) / scrollHeight; if (scrollPosition >= 0.8 && hasNextPage && !isFetchingNextPage && !isFetching) { fetchNextPage(); } }, 200); scrollElement.addEventListener('scroll', throttledScrollHandler, { passive: true }); return () => { scrollElement.removeEventListener('scroll', throttledScrollHandler); throttledScrollHandler.cancel?.(); }; }, [scrollElement, hasNextPage, isFetchingNextPage, isFetching, fetchNextPage, category]); // Separate effect for list re-rendering on data changes useEffect(() => { if (listRef.current) { listRef.current.forceUpdateGrid(); } }, [currentAgents]); // Helper functions for grid calculations const getCardsPerRow = useCallback((width: number) => { return width >= 768 ? CARDS_PER_ROW_DESKTOP : CARDS_PER_ROW_MOBILE; }, []); const getRowCount = useCallback((agentCount: number, cardsPerRow: number) => { return Math.ceil(agentCount / cardsPerRow); }, []); const getRowItems = useCallback( (rowIndex: number, cardsPerRow: number) => { const startIndex = rowIndex * cardsPerRow; const endIndex = Math.min(startIndex + cardsPerRow, currentAgents.length); return currentAgents.slice(startIndex, endIndex); }, [currentAgents], ); const getCategoryDisplayName = (categoryValue: string) => { const categoryData = categories.find((cat) => cat.value === categoryValue); if (categoryData) { return categoryData.label; } if (categoryValue === 'promoted') { return localize('com_agents_top_picks'); } if (categoryValue === 'all') { return 'All'; } return categoryValue.charAt(0).toUpperCase() + categoryValue.slice(1); }; // Row renderer for virtual list const rowRenderer = useCallback( ({ index, key, style, parent }: any) => { const containerWidth = parent?.props?.width || 800; const cardsPerRow = getCardsPerRow(containerWidth); const rowAgents = getRowItems(index, cardsPerRow); const totalRows = getRowCount(currentAgents.length, cardsPerRow); const isLastRow = index === totalRows - 1; const showLoading = isFetchingNextPage && isLastRow; return (
{rowAgents.map((agent: t.Agent, cardIndex: number) => { const globalIndex = index * cardsPerRow + cardIndex; return (
onSelectAgent(agent)} />
); })}
{showLoading && (
{localize('com_agents_loading')}
)}
); }, [ currentAgents, getCardsPerRow, getRowItems, getRowCount, isFetchingNextPage, localize, onSelectAgent, ], ); // Simple loading spinner const loadingSpinner = (
); // Handle error state if (error) { return ( refetch()} context={{ searchQuery, category }} /> ); } // Handle loading state if (isLoading || (isFetching && !isFetchingNextPage)) { return loadingSpinner; } // Handle empty results if ((!currentAgents || currentAgents.length === 0) && !isLoading && !isFetching) { return (

{localize('com_agents_empty_state_heading')}

); } // Main virtualized content return (
{/* Screen reader announcement */}
{localize('com_agents_grid_announcement', { count: currentAgents?.length || 0, category: getCategoryDisplayName(category), })}
{/* Virtualized grid with external scroll integration */}
{scrollElement ? ( {({ height, isScrolling, registerChild, onChildScroll, scrollTop }) => ( {({ width }) => { const cardsPerRow = getCardsPerRow(width); const rowCount = getRowCount(currentAgents.length, cardsPerRow); return (
); }}
)}
) : ( // Fallback for when no external scroll element is provided
{({ width, height }) => { const cardsPerRow = getCardsPerRow(width); const rowCount = getRowCount(currentAgents.length, cardsPerRow); return ( ); }}
)}
{/* End of results indicator */} {!hasNextPage && currentAgents && currentAgents.length > 0 && (

{localize('com_agents_no_more_results')}

)}
); }; export default VirtualizedAgentGrid;