mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-16 20:56:35 +01:00
feat: Enhance CategoryTabs and Marketplace components for better responsiveness and navigation feat: Refactor AgentCard and AgentGrid components for improved layout and accessibility feat: Implement animated category transitions in AgentMarketplace and update NewChat component layout feat: Refactor UI components for improved styling and accessibility in sharing dialogs refactor: remove GenericManagePermissionsDialog and GrantAccessDialog components - Deleted GenericManagePermissionsDialog and GrantAccessDialog components to streamline sharing functionality. - Updated ManagePermissionsDialog to utilize AccessRolesPicker directly. - Introduced UnifiedPeopleSearch for improved people selection experience. - Enhanced PublicSharingToggle with InfoHoverCard for better user guidance. - Adjusted AgentPanel to change error status to warning for duplicate agent versions. - Updated translations to include new keys for search and access management. feat: Add responsive design for SelectedPrincipalsList and improve layout in GenericGrantAccessDialog feat: Enhance styling in SelectedPrincipalsList and SearchPicker components for improved UI consistency feat: Improve PublicSharingToggle component with enhanced styling and accessibility features feat: Introduce InfoHoverCard component and refactor enums for better organization feat: Implement infinite scroll for agent grids and enhance performance - Added `useInfiniteScroll` hook to manage infinite scrolling behavior in agent grids. - Integrated infinite scroll functionality into `AgentGrid` and `VirtualizedAgentGrid` components. - Updated `AgentMarketplace` to pass the scroll container to the agent grid components. - Refactored loading indicators to show a spinner instead of a "Load More" button. - Created `VirtualizedAgentGrid` component for optimized rendering of agent cards using virtualization. - Added performance tests for `VirtualizedAgentGrid` to ensure efficient handling of large datasets. - Updated translations to include new messages for end-of-results scenarios. chore: Remove unused permission-related UI localization keys ci: Update Agent model tests to handle duplicate support_contact updates - Modified tests to ensure that updating an agent with the same support_contact does not create a new version and returns successfully. - Enhanced verification for partial changes in support_contact, confirming no new version is created when content remains the same. chore: Address ESLint, clean up unused imports and improve prop definitions in various components ci: fix tests ci: update tests chore: remove unused search localization keys
348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
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<VirtualizedAgentGridProps> = ({
|
|
category,
|
|
searchQuery,
|
|
onSelectAgent,
|
|
scrollElement,
|
|
}) => {
|
|
const localize = useLocalize();
|
|
const listRef = useRef<VirtualList>(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 (
|
|
<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,
|
|
],
|
|
);
|
|
|
|
// Simple loading spinner
|
|
const loadingSpinner = (
|
|
<div className="flex justify-center py-12">
|
|
<Spinner className="h-8 w-8 text-primary" />
|
|
</div>
|
|
);
|
|
|
|
// Handle error state
|
|
if (error) {
|
|
return (
|
|
<ErrorDisplay
|
|
error={error || 'Unknown error occurred'}
|
|
onRetry={() => refetch()}
|
|
context={{ searchQuery, category }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Handle loading state
|
|
if (isLoading || (isFetching && !isFetchingNextPage)) {
|
|
return loadingSpinner;
|
|
}
|
|
|
|
// Handle empty results
|
|
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>
|
|
);
|
|
}
|
|
|
|
// Main virtualized content
|
|
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;
|