| | 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; |
| | } |
| |
|
| | |
| | const CARD_HEIGHT = 160; |
| | const GAP_SIZE = 24; |
| | const ROW_HEIGHT = CARD_HEIGHT + GAP_SIZE; |
| | const CARDS_PER_ROW_MOBILE = 1; |
| | const CARDS_PER_ROW_DESKTOP = 2; |
| | const OVERSCAN_ROW_COUNT = 3; |
| |
|
| | |
| | |
| | |
| | const VirtualizedAgentGrid: React.FC<VirtualizedAgentGridProps> = ({ |
| | category, |
| | searchQuery, |
| | onSelectAgent, |
| | scrollElement, |
| | }) => { |
| | const localize = useLocalize(); |
| | const listRef = useRef<VirtualList>(null); |
| | const { categories } = useAgentCategories(); |
| |
|
| | |
| | const queryParams = useMemo(() => { |
| | const params: { |
| | requiredPermission: number; |
| | category?: string; |
| | search?: string; |
| | limit: number; |
| | promoted?: 0 | 1; |
| | } = { |
| | requiredPermission: PermissionBits.VIEW, |
| | |
| | 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]); |
| |
|
| | |
| | const { |
| | data, |
| | isLoading, |
| | error, |
| | isFetching, |
| | fetchNextPage, |
| | hasNextPage, |
| | refetch, |
| | isFetchingNextPage, |
| | } = useMarketplaceAgentsInfiniteQuery(queryParams); |
| |
|
| | |
| | const currentAgents = useMemo(() => { |
| | if (!data?.pages) return []; |
| | return data.pages.flatMap((page) => page.data || []); |
| | }, [data?.pages]); |
| |
|
| | const hasData = useHasData(data?.pages?.[0]); |
| |
|
| | |
| | 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]); |
| |
|
| | |
| | useEffect(() => { |
| | if (listRef.current) { |
| | listRef.current.forceUpdateGrid(); |
| | } |
| | }, [currentAgents]); |
| |
|
| | |
| | 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); |
| | }; |
| |
|
| | |
| | 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 ( |
| | <div key={key} style={style}> |
| | <div |
| | className={cn( |
| | 'grid gap-6 px-0', |
| | cardsPerRow === 1 ? 'grid-cols-1' : 'grid-cols-1 md:grid-cols-2', |
| | )} |
| | role="row" |
| | aria-rowindex={index + 1} |
| | > |
| | {rowAgents.map((agent: t.Agent, cardIndex: number) => { |
| | const globalIndex = index * cardsPerRow + cardIndex; |
| | return ( |
| | <div key={`${agent.id}-${globalIndex}`} role="gridcell"> |
| | <AgentCard agent={agent} onClick={() => onSelectAgent(agent)} /> |
| | </div> |
| | ); |
| | })} |
| | </div> |
| | |
| | {showLoading && ( |
| | <div |
| | className="flex justify-center py-4" |
| | role="status" |
| | aria-live="polite" |
| | aria-label={localize('com_agents_loading')} |
| | > |
| | <Spinner className="h-6 w-6 text-primary" /> |
| | <span className="sr-only">{localize('com_agents_loading')}</span> |
| | </div> |
| | )} |
| | </div> |
| | ); |
| | }, |
| | [ |
| | currentAgents, |
| | getCardsPerRow, |
| | getRowItems, |
| | getRowCount, |
| | isFetchingNextPage, |
| | localize, |
| | onSelectAgent, |
| | ], |
| | ); |
| |
|
| | |
| | const loadingSpinner = ( |
| | <div className="flex justify-center py-12"> |
| | <Spinner className="h-8 w-8 text-primary" /> |
| | </div> |
| | ); |
| |
|
| | |
| | if (error) { |
| | return ( |
| | <ErrorDisplay |
| | error={error || 'Unknown error occurred'} |
| | onRetry={() => refetch()} |
| | context={{ searchQuery, category }} |
| | /> |
| | ); |
| | } |
| |
|
| | |
| | if (isLoading || (isFetching && !isFetchingNextPage)) { |
| | return loadingSpinner; |
| | } |
| |
|
| | |
| | if ((!currentAgents || currentAgents.length === 0) && !isLoading && !isFetching) { |
| | return ( |
| | <div |
| | className="py-12 text-center text-text-secondary" |
| | role="status" |
| | aria-live="polite" |
| | aria-label={ |
| | searchQuery |
| | ? localize('com_agents_search_empty_heading') |
| | : localize('com_agents_empty_state_heading') |
| | } |
| | > |
| | <h3 className="mb-2 text-lg font-medium">{localize('com_agents_empty_state_heading')}</h3> |
| | </div> |
| | ); |
| | } |
| |
|
| | |
| | return ( |
| | <div |
| | className="space-y-6" |
| | role="tabpanel" |
| | id={`category-panel-${category}`} |
| | aria-labelledby={`category-tab-${category}`} |
| | aria-live="polite" |
| | aria-busy={isLoading && !hasData} |
| | > |
| | {/* Screen reader announcement */} |
| | <div id="search-results-count" className="sr-only" aria-live="polite" aria-atomic="true"> |
| | {localize('com_agents_grid_announcement', { |
| | count: currentAgents?.length || 0, |
| | category: getCategoryDisplayName(category), |
| | })} |
| | </div> |
| | |
| | {/* Virtualized grid with external scroll integration */} |
| | <div |
| | role="grid" |
| | aria-label={localize('com_agents_grid_announcement', { |
| | count: currentAgents.length, |
| | category: getCategoryDisplayName(category), |
| | })} |
| | > |
| | {scrollElement ? ( |
| | <WindowScroller scrollElement={scrollElement}> |
| | {({ height, isScrolling, registerChild, onChildScroll, scrollTop }) => ( |
| | <AutoSizer disableHeight> |
| | {({ width }) => { |
| | const cardsPerRow = getCardsPerRow(width); |
| | const rowCount = getRowCount(currentAgents.length, cardsPerRow); |
| | |
| | return ( |
| | <div ref={registerChild}> |
| | <VirtualList |
| | ref={listRef} |
| | autoHeight |
| | height={height} |
| | isScrolling={isScrolling} |
| | onScroll={onChildScroll} |
| | overscanRowCount={OVERSCAN_ROW_COUNT} |
| | rowCount={rowCount} |
| | rowHeight={ROW_HEIGHT} |
| | rowRenderer={rowRenderer} |
| | scrollTop={scrollTop} |
| | width={width} |
| | style={{ outline: 'none' }} |
| | aria-rowcount={rowCount} |
| | data-testid="virtual-list" |
| | data-total-rows={rowCount} |
| | /> |
| | </div> |
| | ); |
| | }} |
| | </AutoSizer> |
| | )} |
| | </WindowScroller> |
| | ) : ( |
| | // Fallback for when no external scroll element is provided |
| | <div style={{ height: 600 }}> |
| | <AutoSizer> |
| | {({ width, height }) => { |
| | const cardsPerRow = getCardsPerRow(width); |
| | const rowCount = getRowCount(currentAgents.length, cardsPerRow); |
| | |
| | return ( |
| | <VirtualList |
| | ref={listRef} |
| | height={height} |
| | overscanRowCount={OVERSCAN_ROW_COUNT} |
| | rowCount={rowCount} |
| | rowHeight={ROW_HEIGHT} |
| | rowRenderer={rowRenderer} |
| | width={width} |
| | style={{ outline: 'none' }} |
| | aria-rowcount={rowCount} |
| | data-testid="virtual-list" |
| | data-total-rows={rowCount} |
| | /> |
| | ); |
| | }} |
| | </AutoSizer> |
| | </div> |
| | )} |
| | </div> |
| | |
| | {/* End of results indicator */} |
| | {!hasNextPage && currentAgents && currentAgents.length > 0 && ( |
| | <div className="mt-8 text-center"> |
| | <p className="text-sm text-text-secondary">{localize('com_agents_no_more_results')}</p> |
| | </div> |
| | )} |
| | </div> |
| | ); |
| | }; |
| |
|
| | export default VirtualizedAgentGrid; |
| |
|