{/* Scrollable container */} -
+
{/* Simplified header for agents marketplace - only show nav controls when needed */} {!isSmallScreen && (
@@ -436,6 +442,7 @@ const AgentMarketplace: React.FC = ({ className = '' }) = category={displayCategory} searchQuery={searchQuery} onSelectAgent={handleAgentSelect} + scrollElement={scrollContainerRef.current} />
@@ -509,6 +516,7 @@ const AgentMarketplace: React.FC = ({ className = '' }) = category={nextCategory} searchQuery={searchQuery} onSelectAgent={handleAgentSelect} + scrollElement={scrollContainerRef.current} />
)} diff --git a/client/src/components/Agents/VirtualizedAgentGrid.tsx b/client/src/components/Agents/VirtualizedAgentGrid.tsx new file mode 100644 index 0000000000..0fed2c1974 --- /dev/null +++ b/client/src/components/Agents/VirtualizedAgentGrid.tsx @@ -0,0 +1,348 @@ +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; diff --git a/client/src/components/Agents/tests/AgentGrid.integration.spec.tsx b/client/src/components/Agents/tests/AgentGrid.integration.spec.tsx index c429bf769e..6f8aac8bae 100644 --- a/client/src/components/Agents/tests/AgentGrid.integration.spec.tsx +++ b/client/src/components/Agents/tests/AgentGrid.integration.spec.tsx @@ -37,7 +37,7 @@ jest.mock('~/hooks/useLocalize', () => () => (key: string, options?: any) => { com_agents_empty_state_heading: 'No agents available', com_agents_loading: 'Loading...', com_agents_grid_announcement: '{{count}} agents in {{category}}', - com_agents_load_more_label: 'Load more agents from {{category}}', + com_agents_no_more_results: "You've reached the end of the results", }; let translation = mockTranslations[key] || key; @@ -341,24 +341,12 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => { }); }); - describe('Load More Functionality', () => { - it('should show "See more" button when hasNextPage is true', () => { - const Wrapper = createWrapper(); - render( - - - , - ); - - expect( - screen.getByRole('button', { name: 'Load more agents from Finance' }), - ).toBeInTheDocument(); - }); - - it('should not show "See more" button when hasNextPage is false', () => { + describe('Infinite Scroll Functionality', () => { + it('should show loading indicator when fetching next page', () => { mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({ ...defaultMockQueryResult, - hasNextPage: false, + isFetchingNextPage: true, + hasNextPage: true, }); const Wrapper = createWrapper(); @@ -368,7 +356,44 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => { , ); - expect(screen.queryByRole('button', { name: /Load more agents/ })).not.toBeInTheDocument(); + expect(screen.getByRole('status', { name: 'Loading...' })).toBeInTheDocument(); + expect(screen.getByText('Loading...')).toHaveClass('sr-only'); + }); + + it('should show end of results message when hasNextPage is false and agents exist', () => { + mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({ + ...defaultMockQueryResult, + hasNextPage: false, + isFetchingNextPage: false, + }); + + const Wrapper = createWrapper(); + render( + + + , + ); + + expect(screen.getByText("You've reached the end of the results")).toBeInTheDocument(); + }); + + it('should not show end of results message when no agents exist', () => { + mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({ + ...defaultMockQueryResult, + hasNextPage: false, + data: { + pages: [{ data: [] }], + }, + }); + + const Wrapper = createWrapper(); + render( + + + , + ); + + expect(screen.queryByText("You've reached the end of the results")).not.toBeInTheDocument(); }); }); }); diff --git a/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx b/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx new file mode 100644 index 0000000000..04003a34a4 --- /dev/null +++ b/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx @@ -0,0 +1,212 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { jest } from '@jest/globals'; +import VirtualizedAgentGrid from '../VirtualizedAgentGrid'; + +// Mock react-virtualized for performance testing +const mockRowRenderer = jest.fn(); +jest.mock('react-virtualized', () => ({ + AutoSizer: ({ + children, + }: { + children: (props: { width: number; height: number }) => React.ReactNode; + }) => children({ width: 1200, height: 800 }), + List: ({ rowRenderer, rowCount }: { rowRenderer: any; rowCount: number }) => { + mockRowRenderer.mockImplementation(rowRenderer); + // Only render visible rows to simulate virtualization + const visibleRows = Math.min(10, rowCount); // Simulate 10 visible rows + return ( +
+ {Array.from({ length: visibleRows }, (_, index) => + rowRenderer({ + index, + key: `row-${index}`, + style: { height: 184 }, + parent: { props: { width: 1200 } }, + }), + )} +
+ ); + }, +})); + +// Generate large dataset for performance testing +const generateLargeDataset = (count: number) => { + const agents = []; + for (let i = 1; i <= count; i++) { + agents.push({ + id: `agent-${i}`, + name: `Performance Test Agent ${i}`, + description: `This is agent ${i} for performance testing virtual scrolling with large datasets`, + category: i % 2 === 0 ? 'productivity' : 'development', + }); + } + return agents; +}; + +// Mock the data provider with large dataset +const createMockInfiniteQuery = (agentCount: number) => ({ + data: { + pages: [{ data: generateLargeDataset(agentCount) }], + }, + isLoading: false, + error: null, + isFetching: false, + fetchNextPage: jest.fn(), + hasNextPage: false, + refetch: jest.fn(), + isFetchingNextPage: false, +}); + +const mockUseQuery = jest.fn(); +jest.mock('~/data-provider/Agents', () => ({ + useMarketplaceAgentsInfiniteQuery: mockUseQuery, +})); +jest.mock('~/hooks', () => ({ + useAgentCategories: () => ({ + categories: [ + { value: 'productivity', label: 'Productivity' }, + { value: 'development', label: 'Development' }, + ], + }), + useLocalize: () => (key: string, params?: any) => { + if (key === 'com_agents_grid_announcement') { + return `Found ${params?.count || 0} agents in ${params?.category || 'category'}`; + } + return key; + }, +})); + +jest.mock('../SmartLoader', () => ({ + useHasData: () => true, +})); + +jest.mock('~/hooks/useInfiniteScroll', () => ({ + useInfiniteScroll: () => ({ + setScrollElement: jest.fn(), + }), +})); + +jest.mock('../AgentCard', () => { + return function MockAgentCard({ agent }: { agent: any }) { + return ( +
+

{agent.name}

+

{agent.description}

+
+ ); + }; +}); + +describe('Virtual Scrolling Performance', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + mockRowRenderer.mockClear(); + }); + + const renderComponent = (agentCount: number) => { + const mockQuery = createMockInfiniteQuery(agentCount); + mockUseQuery.mockReturnValue(mockQuery); + + return render( + + + , + ); + }; + + it('efficiently handles 1000 agents without rendering all DOM nodes', () => { + const startTime = performance.now(); + renderComponent(1000); + const endTime = performance.now(); + + const virtualList = screen.getByTestId('virtual-list'); + expect(virtualList).toBeInTheDocument(); + expect(virtualList).toHaveAttribute('data-total-rows', '500'); // 1000 agents / 2 per row + + // Should only render visible cards, not all 1000 + const renderedCards = screen.getAllByTestId(/agent-card-/); + expect(renderedCards.length).toBeLessThan(50); // Much less than 1000 + expect(renderedCards.length).toBeGreaterThan(0); + + // Performance check: rendering should be fast + const renderTime = endTime - startTime; + expect(renderTime).toBeLessThan(100); // Should render in less than 100ms + + console.log(`Rendered 1000 agents in ${renderTime.toFixed(2)}ms`); + console.log(`Only ${renderedCards.length} DOM nodes created for 1000 agents`); + }); + + it('efficiently handles 5000 agents (stress test)', () => { + const startTime = performance.now(); + renderComponent(5000); + const endTime = performance.now(); + + const virtualList = screen.getByTestId('virtual-list'); + expect(virtualList).toBeInTheDocument(); + expect(virtualList).toHaveAttribute('data-total-rows', '2500'); // 5000 agents / 2 per row + + // Should still only render visible cards + const renderedCards = screen.getAllByTestId(/agent-card-/); + expect(renderedCards.length).toBeLessThan(50); + expect(renderedCards.length).toBeGreaterThan(0); + + // Performance should still be reasonable + const renderTime = endTime - startTime; + expect(renderTime).toBeLessThan(200); // Should render in less than 200ms + + console.log(`Rendered 5000 agents in ${renderTime.toFixed(2)}ms`); + console.log(`Only ${renderedCards.length} DOM nodes created for 5000 agents`); + }); + + it('calculates correct number of virtual rows for different screen sizes', () => { + // Test desktop layout (2 cards per row) + renderComponent(100); + + const virtualList = screen.getByTestId('virtual-list'); + expect(virtualList).toHaveAttribute('data-total-rows', '50'); // 100 agents / 2 per row + }); + + it('row renderer is called efficiently', () => { + renderComponent(1000); + + // Row renderer should be called, but not 500 times (since we virtualize) + expect(mockRowRenderer).toHaveBeenCalled(); + expect(mockRowRenderer.mock.calls.length).toBeLessThan(100); + expect(mockRowRenderer.mock.calls.length).toBeGreaterThan(0); + }); + + it('memory usage remains stable with large datasets', () => { + // Test that memory doesn't grow linearly with data size + const measureMemory = () => { + const cards = screen.queryAllByTestId(/agent-card-/); + return cards.length; + }; + + renderComponent(100); + const memory100 = measureMemory(); + + renderComponent(1000); + const memory1000 = measureMemory(); + + renderComponent(5000); + const memory5000 = measureMemory(); + + // Memory usage should not scale linearly with data size + // All should render roughly the same number of DOM nodes + expect(Math.abs(memory100 - memory1000)).toBeLessThan(10); + expect(Math.abs(memory1000 - memory5000)).toBeLessThan(10); + + console.log( + `Memory usage: 100 agents=${memory100}, 1000 agents=${memory1000}, 5000 agents=${memory5000}`, + ); + }); +}); diff --git a/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx b/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx new file mode 100644 index 0000000000..987038fec7 --- /dev/null +++ b/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx @@ -0,0 +1,194 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { jest } from '@jest/globals'; +import VirtualizedAgentGrid from '../VirtualizedAgentGrid'; +import type t from 'librechat-data-provider'; + +// Mock react-virtualized +jest.mock('react-virtualized', () => ({ + AutoSizer: ({ + children, + }: { + children: (props: { width: number; height: number }) => React.ReactNode; + }) => children({ width: 800, height: 600 }), + List: ({ rowRenderer, rowCount }: { rowRenderer: any; rowCount: number }) => ( +
+ {Array.from({ length: Math.min(rowCount, 5) }, (_, index) => + rowRenderer({ + index, + key: `row-${index}`, + style: {}, + parent: { props: { width: 800 } }, + }), + )} +
+ ), +})); + +// Mock the data provider +const mockInfiniteQuery = { + data: { + pages: [ + { + data: [ + { + id: '1', + name: 'Test Agent 1', + description: 'A test agent for virtual scrolling', + category: 'productivity', + }, + { + id: '2', + name: 'Test Agent 2', + description: 'Another test agent', + category: 'development', + }, + ], + }, + ], + }, + isLoading: false, + error: null, + isFetching: false, + fetchNextPage: jest.fn(), + hasNextPage: true, + refetch: jest.fn(), + isFetchingNextPage: false, +}; + +jest.mock('~/data-provider/Agents', () => ({ + useMarketplaceAgentsInfiniteQuery: jest.fn(() => mockInfiniteQuery), +})); + +// Mock other hooks +jest.mock('~/hooks', () => ({ + useAgentCategories: () => ({ + categories: [ + { value: 'productivity', label: 'Productivity' }, + { value: 'development', label: 'Development' }, + ], + }), + useLocalize: () => (key: string, params?: any) => { + if (key === 'com_agents_grid_announcement') { + return `Found ${params?.count || 0} agents in ${params?.category || 'category'}`; + } + return key; + }, +})); + +jest.mock('../SmartLoader', () => ({ + useHasData: () => true, +})); + +jest.mock('~/hooks/useInfiniteScroll', () => ({ + useInfiniteScroll: () => ({ + setScrollElement: jest.fn(), + }), +})); + +jest.mock('../AgentCard', () => { + return function MockAgentCard({ agent, onClick }: { agent: t.Agent; onClick: () => void }) { + return ( +
+

{agent.name}

+

{agent.description}

+
+ ); + }; +}); + +describe('VirtualizedAgentGrid', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + }); + + const renderComponent = (props = {}) => { + const defaultProps = { + category: 'all', + searchQuery: '', + onSelectAgent: jest.fn(), + }; + + return render( + + + , + ); + }; + + it('renders virtual list container', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByTestId('virtual-list')).toBeInTheDocument(); + }); + }); + + it('displays agent cards in virtual rows', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByTestId('agent-card-1')).toBeInTheDocument(); + expect(screen.getByTestId('agent-card-2')).toBeInTheDocument(); + }); + + expect(screen.getByText('Test Agent 1')).toBeInTheDocument(); + expect(screen.getByText('Test Agent 2')).toBeInTheDocument(); + }); + + it('calls onSelectAgent when agent card is clicked', async () => { + const onSelectAgent = jest.fn(); + renderComponent({ onSelectAgent }); + + await waitFor(() => { + expect(screen.getByTestId('agent-card-1')).toBeInTheDocument(); + }); + + screen.getByTestId('agent-card-1').click(); + + expect(onSelectAgent).toHaveBeenCalledWith({ + id: '1', + name: 'Test Agent 1', + description: 'A test agent for virtual scrolling', + category: 'productivity', + }); + }); + + it('shows loading spinner when loading', async () => { + const mockQuery = jest.fn(() => ({ + ...mockInfiniteQuery, + isLoading: true, + data: undefined, + })); + + const useMarketplaceAgentsInfiniteQuery = + jest.requireMock('~/data-provider/Agents').useMarketplaceAgentsInfiniteQuery; + useMarketplaceAgentsInfiniteQuery.mockImplementation(mockQuery); + + renderComponent(); + + // Should show loading spinner instead of skeleton + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('has proper accessibility attributes', async () => { + renderComponent({ category: 'productivity' }); + + await waitFor(() => { + const gridContainer = screen.getByRole('grid'); + expect(gridContainer).toHaveAttribute('aria-label'); + + const tabpanel = screen.getByRole('tabpanel'); + expect(tabpanel).toHaveAttribute('id', 'category-panel-productivity'); + expect(tabpanel).toHaveAttribute('aria-labelledby', 'category-tab-productivity'); + }); + }); +}); diff --git a/client/src/hooks/useInfiniteScroll.ts b/client/src/hooks/useInfiniteScroll.ts new file mode 100644 index 0000000000..4c1ac8dae9 --- /dev/null +++ b/client/src/hooks/useInfiniteScroll.ts @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { throttle } from 'lodash'; + +interface UseInfiniteScrollOptions { + hasNextPage?: boolean; + isFetchingNextPage?: boolean; + fetchNextPage: () => void; + threshold?: number; // Percentage of scroll position to trigger fetch (0-1) + throttleMs?: number; // Throttle delay in milliseconds +} + +/** + * Custom hook for implementing infinite scroll functionality + * Detects when user scrolls near the bottom and triggers data fetching + */ +export const useInfiniteScroll = ({ + hasNextPage = false, + isFetchingNextPage = false, + fetchNextPage, + threshold = 0.8, // Trigger when 80% scrolled + throttleMs = 200, +}: UseInfiniteScrollOptions) => { + const scrollElementRef = useRef(null); + + // Throttled scroll handler to prevent excessive API calls + const handleScroll = useCallback( + throttle(() => { + const element = scrollElementRef.current; + if (!element) return; + + const { scrollTop, scrollHeight, clientHeight } = element; + + // Calculate scroll position as percentage + const scrollPosition = (scrollTop + clientHeight) / scrollHeight; + + // Check if we've scrolled past the threshold and conditions are met + const shouldFetch = scrollPosition >= threshold && hasNextPage && !isFetchingNextPage; + + if (shouldFetch) { + fetchNextPage(); + } + }, throttleMs), + [hasNextPage, isFetchingNextPage, fetchNextPage, threshold, throttleMs], + ); + + // Set up scroll listener + useEffect(() => { + const element = scrollElementRef.current; + if (!element) return; + + // Remove any existing listener first + element.removeEventListener('scroll', handleScroll); + + // Add the new listener + element.addEventListener('scroll', handleScroll, { passive: true }); + + return () => { + element.removeEventListener('scroll', handleScroll); + // Clean up throttled function + handleScroll.cancel?.(); + }; + }, [handleScroll]); + + // Additional effect to re-setup listeners when scroll element changes + useEffect(() => { + const element = scrollElementRef.current; + if (!element) return; + // Remove any existing listener first + element.removeEventListener('scroll', handleScroll); + + // Add the new listener + element.addEventListener('scroll', handleScroll, { passive: true }); + return () => { + element.removeEventListener('scroll', handleScroll); + // Clean up throttled function + handleScroll.cancel?.(); + }; + }, [scrollElementRef.current, handleScroll]); + + // Function to manually set the scroll container + const setScrollElement = useCallback((element: HTMLElement | null) => { + scrollElementRef.current = element; + }, []); + + return { + setScrollElement, + scrollElementRef, + }; +}; + +export default useInfiniteScroll; diff --git a/client/src/hooks/useVirtualGrid.ts b/client/src/hooks/useVirtualGrid.ts new file mode 100644 index 0000000000..edff196f64 --- /dev/null +++ b/client/src/hooks/useVirtualGrid.ts @@ -0,0 +1,66 @@ +import { useCallback, useMemo } from 'react'; + +interface UseVirtualGridProps { + itemCount: number; + containerWidth: number; + itemHeight: number; + gapSize: number; + mobileColumnsCount: number; + desktopColumnsCount: number; + mobileBreakpoint: number; +} + +interface UseVirtualGridReturn { + cardsPerRow: number; + rowCount: number; + rowHeight: number; + getRowItems: (rowIndex: number, items: any[]) => any[]; +} + +/** + * Custom hook for virtual grid calculations + * Handles responsive grid layout and item positioning for virtualized lists + */ +export const useVirtualGrid = ({ + itemCount, + containerWidth, + itemHeight, + gapSize, + mobileColumnsCount, + desktopColumnsCount, + mobileBreakpoint = 768, +}: UseVirtualGridProps): UseVirtualGridReturn => { + // Calculate cards per row based on container width + const cardsPerRow = useMemo(() => { + return containerWidth >= mobileBreakpoint ? desktopColumnsCount : mobileColumnsCount; + }, [containerWidth, mobileBreakpoint, desktopColumnsCount, mobileColumnsCount]); + + // Calculate total number of rows needed + const rowCount = useMemo(() => { + return Math.ceil(itemCount / cardsPerRow); + }, [itemCount, cardsPerRow]); + + // Calculate row height including gap + const rowHeight = useMemo(() => { + return itemHeight + gapSize; + }, [itemHeight, gapSize]); + + // Get items for a specific row + const getRowItems = useCallback( + (rowIndex: number, items: any[]) => { + const startIndex = rowIndex * cardsPerRow; + const endIndex = Math.min(startIndex + cardsPerRow, items.length); + return items.slice(startIndex, endIndex); + }, + [cardsPerRow], + ); + + return { + cardsPerRow, + rowCount, + rowHeight, + getRowItems, + }; +}; + +export default useVirtualGrid; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 73e08f6961..6f6221a44a 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1237,6 +1237,7 @@ "com_agents_no_description": "No description available", "com_agents_results_for": "Results for '{{query}}'", "com_agents_marketplace_subtitle": "Discover and use powerful AI agents to enhance your workflows and productivity", + "com_agents_no_more_results": "You've reached the end of the results", "com_ui_agent_name_is_required": "Agent name is required", "com_agents_missing_name": "Please type in a name before creating an agent." }