mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-24 11:24:10 +01:00
🖼️ style: Improve Marketplace & Sharing Dialog UI
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
This commit is contained in:
parent
9585db14ba
commit
d82a63642d
51 changed files with 2074 additions and 1311 deletions
|
|
@ -1,7 +1,8 @@
|
|||
import React from 'react';
|
||||
import { Label } from '@librechat/client';
|
||||
import type t from 'librechat-data-provider';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: t.Agent; // The agent data to display
|
||||
|
|
@ -18,10 +19,10 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex overflow-hidden rounded-2xl',
|
||||
'cursor-pointer transition-colors duration-200',
|
||||
'aspect-[5/2.5] w-full',
|
||||
'bg-surface-tertiary hover:bg-surface-hover-alt',
|
||||
'group relative h-40 overflow-hidden rounded-xl border border-border-light',
|
||||
'cursor-pointer shadow-sm transition-all duration-200 hover:border-border-medium hover:shadow-lg',
|
||||
'bg-surface-tertiary hover:bg-surface-hover',
|
||||
'space-y-3 p-4',
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
|
|
@ -39,50 +40,57 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
|
|||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full gap-2 px-3 py-2 sm:gap-3 sm:px-4 sm:py-3">
|
||||
{/* Agent avatar section - left side, responsive */}
|
||||
<div className="flex flex-shrink-0 items-center">
|
||||
{renderAgentAvatar(agent, { size: 'md' })}
|
||||
{/* Two column layout */}
|
||||
<div className="flex h-full items-start gap-3">
|
||||
{/* Left column: Avatar and Category */}
|
||||
<div className="flex h-full flex-shrink-0 flex-col justify-between space-y-4">
|
||||
<div className="flex-shrink-0">{renderAgentAvatar(agent, { size: 'sm' })}</div>
|
||||
|
||||
{/* Category tag */}
|
||||
<div className="inline-flex items-center rounded-md border-border-xheavy bg-surface-active-alt px-2 py-1 text-xs font-medium">
|
||||
{agent.category && (
|
||||
<Label className="line-clamp-1 font-normal">
|
||||
{agent.category.charAt(0).toUpperCase() + agent.category.slice(1)}
|
||||
</Label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent info section - right side, responsive */}
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
||||
{/* Agent name - responsive text sizing */}
|
||||
<h3 className="mb-1 line-clamp-1 text-base font-bold text-text-primary sm:mb-2 sm:text-lg">
|
||||
{agent.name}
|
||||
</h3>
|
||||
{/* Right column: Name, description, and other content */}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Agent name */}
|
||||
<Label className="mb-1 line-clamp-1 text-xl font-semibold text-text-primary">
|
||||
{agent.name}
|
||||
</Label>
|
||||
|
||||
{/* Agent description - responsive text sizing and spacing */}
|
||||
{/* Owner info */}
|
||||
{(() => {
|
||||
const displayName = getContactDisplayName(agent);
|
||||
if (displayName) {
|
||||
return (
|
||||
<div className="flex items-center text-sm text-text-secondary">
|
||||
<Label className="mr-1">🔹</Label>
|
||||
<Label>{displayName}</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Agent description */}
|
||||
<p
|
||||
id={`agent-${agent.id}-description`}
|
||||
className={cn(
|
||||
'mb-1 line-clamp-2 text-xs leading-relaxed text-text-secondary',
|
||||
'sm:mb-2 sm:text-sm',
|
||||
)}
|
||||
className="line-clamp-3 text-sm leading-relaxed text-text-primary"
|
||||
aria-label={`Description: ${agent.description || localize('com_agents_no_description')}`}
|
||||
>
|
||||
{agent.description || (
|
||||
<span className="italic text-text-secondary">
|
||||
<Label className="font-normal italic text-text-primary">
|
||||
{localize('com_agents_no_description')}
|
||||
</span>
|
||||
</Label>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Owner info - responsive text sizing */}
|
||||
{(() => {
|
||||
const displayName = getContactDisplayName(agent);
|
||||
|
||||
if (displayName) {
|
||||
return (
|
||||
<div className="flex items-center text-xs text-text-tertiary sm:text-sm">
|
||||
<span className="font-light">{localize('com_agents_created_by')}</span>
|
||||
<span className="ml-1 font-bold">{displayName}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,30 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { Button, Spinner } from '@librechat/client';
|
||||
import React, { useMemo, useEffect } from 'react';
|
||||
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 { useInfiniteScroll } from '~/hooks/useInfiniteScroll';
|
||||
import { useHasData } from './SmartLoader';
|
||||
import ErrorDisplay from './ErrorDisplay';
|
||||
import AgentCard from './AgentCard';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface AgentGridProps {
|
||||
category: string; // Currently selected category
|
||||
searchQuery: string; // Current search query
|
||||
onSelectAgent: (agent: t.Agent) => void; // Callback when agent is selected
|
||||
scrollElement?: HTMLElement | null; // Parent scroll container for infinite scroll
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for displaying a grid of agent cards
|
||||
*/
|
||||
const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAgent }) => {
|
||||
const AgentGrid: React.FC<AgentGridProps> = ({
|
||||
category,
|
||||
searchQuery,
|
||||
onSelectAgent,
|
||||
scrollElement,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
// Get category data from API
|
||||
|
|
@ -78,6 +84,26 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
|||
// Check if we have meaningful data to prevent unnecessary loading states
|
||||
const hasData = useHasData(data?.pages?.[0]);
|
||||
|
||||
// Set up infinite scroll
|
||||
const { setScrollElement } = useInfiniteScroll({
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage: () => {
|
||||
if (hasNextPage && !isFetching) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
threshold: 0.8, // Trigger when 80% scrolled
|
||||
throttleMs: 200,
|
||||
});
|
||||
|
||||
// Connect the scroll element when it's provided
|
||||
useEffect(() => {
|
||||
if (scrollElement) {
|
||||
setScrollElement(scrollElement);
|
||||
}
|
||||
}, [scrollElement, setScrollElement]);
|
||||
|
||||
/**
|
||||
* Get category display name from API data or use fallback
|
||||
*/
|
||||
|
|
@ -99,59 +125,10 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
|||
return categoryValue.charAt(0).toUpperCase() + categoryValue.slice(1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Load more agents when "See More" button is clicked
|
||||
*/
|
||||
const handleLoadMore = () => {
|
||||
if (hasNextPage && !isFetching) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the appropriate title for the agents grid based on current state
|
||||
*/
|
||||
const getGridTitle = () => {
|
||||
if (searchQuery) {
|
||||
return localize('com_agents_results_for', { query: searchQuery });
|
||||
}
|
||||
|
||||
return getCategoryDisplayName(category);
|
||||
};
|
||||
|
||||
// Loading skeleton component
|
||||
const loadingSkeleton = (
|
||||
<div className="space-y-6">
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 h-6 w-48 animate-pulse rounded-md bg-surface-tertiary"></div>
|
||||
<div className="h-4 w-64 animate-pulse rounded-md bg-surface-tertiary"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{Array(6)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'flex animate-pulse overflow-hidden rounded-2xl',
|
||||
'aspect-[5/2.5] w-full',
|
||||
'bg-surface-tertiary',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full gap-2 px-3 py-2 sm:gap-3 sm:px-4 sm:py-3">
|
||||
{/* Avatar skeleton */}
|
||||
<div className="flex flex-shrink-0 items-center">
|
||||
<div className="h-10 w-10 rounded-full bg-surface-secondary sm:h-12 sm:w-12"></div>
|
||||
</div>
|
||||
{/* Content skeleton */}
|
||||
<div className="flex flex-1 flex-col justify-center space-y-2">
|
||||
<div className="h-4 w-3/4 rounded bg-surface-secondary"></div>
|
||||
<div className="h-3 w-full rounded bg-surface-secondary"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
// Simple loading spinner
|
||||
const loadingSpinner = (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spinner className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -179,19 +156,6 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
|||
aria-live="polite"
|
||||
aria-busy={isLoading && !hasData}
|
||||
>
|
||||
{/* Grid title - only show for search results */}
|
||||
{searchQuery && (
|
||||
<div className="mb-4">
|
||||
<h2
|
||||
className="text-xl font-bold text-text-primary"
|
||||
id={`category-heading-${category}`}
|
||||
aria-label={`${getGridTitle()}, ${currentAgents.length || 0} agents available`}
|
||||
>
|
||||
{getGridTitle()}
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Handle empty results with enhanced accessibility */}
|
||||
{(!currentAgents || currentAgents.length === 0) && !isLoading && !isFetching ? (
|
||||
<div
|
||||
|
|
@ -204,16 +168,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
|||
: localize('com_agents_empty_state_heading')
|
||||
}
|
||||
>
|
||||
<h3 className="mb-2 text-lg font-medium">
|
||||
{searchQuery
|
||||
? localize('com_agents_search_empty_heading')
|
||||
: localize('com_agents_empty_state_heading')}
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
{searchQuery
|
||||
? localize('com_agents_no_results')
|
||||
: localize('com_agents_none_in_category')}
|
||||
</p>
|
||||
<h3 className="mb-2 text-lg font-medium">{localize('com_agents_empty_state_heading')}</h3>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -244,9 +199,9 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
|||
)}
|
||||
|
||||
{/* Loading indicator when fetching more with accessibility */}
|
||||
{isFetching && hasNextPage && (
|
||||
{isFetchingNextPage && (
|
||||
<div
|
||||
className="flex justify-center py-4"
|
||||
className="flex justify-center py-8"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={localize('com_agents_loading')}
|
||||
|
|
@ -256,23 +211,12 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more button with enhanced accessibility */}
|
||||
{hasNextPage && !isFetching && (
|
||||
<div className="mt-8 flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleLoadMore}
|
||||
className={cn(
|
||||
'min-w-[160px] border-2 border-border-medium bg-surface-primary px-6 py-3 font-medium text-text-primary',
|
||||
'shadow-sm transition-all duration-200 hover:border-border-heavy hover:bg-surface-hover',
|
||||
'hover:shadow-md focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
)}
|
||||
aria-label={localize('com_agents_load_more_label', {
|
||||
category: getCategoryDisplayName(category),
|
||||
})}
|
||||
>
|
||||
{localize('com_agents_see_more')}
|
||||
</Button>
|
||||
{/* 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>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -281,7 +225,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
|||
);
|
||||
|
||||
if (isLoading || (isFetching && !isFetchingNextPage)) {
|
||||
return loadingSkeleton;
|
||||
return loadingSpinner;
|
||||
}
|
||||
return mainContent;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ interface CategoryTabsProps {
|
|||
* Renders a tabbed navigation interface showing agent categories.
|
||||
* Includes loading states, empty state handling, and displays counts for each category.
|
||||
* Uses database-driven category labels with no hardcoded values.
|
||||
* Features multi-row wrapping for better responsive behavior.
|
||||
*/
|
||||
const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||
categories,
|
||||
|
|
@ -46,14 +47,13 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
|||
return category.label || category.value.charAt(0).toUpperCase() + category.value.slice(1);
|
||||
};
|
||||
|
||||
// Loading skeleton component
|
||||
const loadingSkeleton = (
|
||||
<div className="w-full pb-2">
|
||||
<div className="no-scrollbar flex gap-1.5 overflow-x-auto px-4">
|
||||
<div className="flex flex-wrap justify-center gap-1.5 px-4">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[36px] min-w-[80px] animate-pulse rounded-md bg-surface-tertiary"
|
||||
className="h-[36px] min-w-[80px] animate-pulse rounded-lg bg-surface-tertiary"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -67,15 +67,23 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
|||
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
newIndex = currentIndex > 0 ? currentIndex - 1 : categories.length - 1;
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
newIndex = currentIndex < categories.length - 1 ? currentIndex + 1 : 0;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
// Move up a row (approximate by moving back ~4-6 items)
|
||||
newIndex = Math.max(0, currentIndex - 5);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
// Move down a row (approximate by moving forward ~4-6 items)
|
||||
newIndex = Math.min(categories.length - 1, currentIndex + 5);
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
newIndex = 0;
|
||||
|
|
@ -94,7 +102,9 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
|||
// Focus the new tab
|
||||
setTimeout(() => {
|
||||
const newTab = document.getElementById(`category-tab-${newCategory.value}`);
|
||||
newTab?.focus();
|
||||
if (newTab) {
|
||||
newTab.focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
|
@ -108,16 +118,12 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
|||
|
||||
// Main tabs content
|
||||
const tabsContent = (
|
||||
<div className="relative w-full pb-2">
|
||||
<div className="w-full pb-2">
|
||||
<div
|
||||
className="no-scrollbar flex gap-1.5 overflow-x-auto overscroll-x-contain px-4 [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
className="flex flex-wrap justify-center gap-1.5 px-4"
|
||||
role="tablist"
|
||||
aria-label={localize('com_agents_category_tabs_label')}
|
||||
aria-orientation="horizontal"
|
||||
style={{
|
||||
scrollSnapType: 'x mandatory',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{categories.map((category, index) => (
|
||||
<button
|
||||
|
|
@ -126,14 +132,11 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
|||
onClick={() => onChange(category.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, category.value)}
|
||||
className={cn(
|
||||
'relative mt-1 cursor-pointer select-none whitespace-nowrap rounded-md px-3 py-2',
|
||||
'relative cursor-pointer select-none whitespace-nowrap px-3 py-2 transition-colors',
|
||||
activeTab === category.value
|
||||
? 'bg-surface-tertiary text-text-primary'
|
||||
: 'bg-surface-secondary text-text-secondary hover:bg-surface-hover hover:text-text-primary',
|
||||
? 'rounded-t-lg bg-surface-hover text-text-primary'
|
||||
: 'rounded-lg bg-surface-secondary text-text-secondary hover:bg-surface-hover hover:text-text-primary',
|
||||
)}
|
||||
style={{
|
||||
scrollSnapAlign: 'start',
|
||||
}}
|
||||
role="tab"
|
||||
aria-selected={activeTab === category.value}
|
||||
aria-controls={`tabpanel-${category.value}`}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
|
@ -43,11 +43,24 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
|||
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
|
||||
const [hideSidePanel, setHideSidePanel] = useRecoilState(store.hideSidePanel);
|
||||
|
||||
// Get URL parameters (default to 'promoted' instead of 'all')
|
||||
const activeTab = category || 'promoted';
|
||||
// Get URL parameters (default to 'all' to ensure users see agents)
|
||||
const activeTab = category || 'all';
|
||||
const searchQuery = searchParams.get('q') || '';
|
||||
const selectedAgentId = searchParams.get('agent_id') || '';
|
||||
|
||||
// Animation state
|
||||
type Direction = 'left' | 'right';
|
||||
const [displayCategory, setDisplayCategory] = useState<string>(activeTab);
|
||||
const [nextCategory, setNextCategory] = useState<string | null>(null);
|
||||
const [isTransitioning, setIsTransitioning] = useState<boolean>(false);
|
||||
const [animationDirection, setAnimationDirection] = useState<Direction>('right');
|
||||
|
||||
// Keep a ref of initial mount to avoid animating first sync
|
||||
const didInitRef = useRef(false);
|
||||
|
||||
// Ref for the scrollable container to enable infinite scroll
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Local state
|
||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||
const [selectedAgent, setSelectedAgent] = useState<t.Agent | null>(null);
|
||||
|
|
@ -64,6 +77,15 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
|||
localStorage.setItem('fullPanelCollapse', 'false');
|
||||
}, [setHideSidePanel, hideSidePanel]);
|
||||
|
||||
// Redirect base /agents route to /agents/all for consistency
|
||||
useEffect(() => {
|
||||
if (!category && window.location.pathname === '/agents') {
|
||||
const currentSearchParams = searchParams.toString();
|
||||
const searchParamsStr = currentSearchParams ? `?${currentSearchParams}` : '';
|
||||
navigate(`/agents/all${searchParamsStr}`, { replace: true });
|
||||
}
|
||||
}, [category, navigate, searchParams]);
|
||||
|
||||
// Ensure endpoints config is loaded first (required for agent queries)
|
||||
useGetEndpointsQuery();
|
||||
|
||||
|
|
@ -101,22 +123,92 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
|||
};
|
||||
|
||||
/**
|
||||
* Handle category tab selection changes
|
||||
*
|
||||
* @param tabValue - The selected category value
|
||||
* Determine ordered tabs to compute indices for direction
|
||||
*/
|
||||
const orderedTabs = useMemo<string[]>(() => {
|
||||
const dynamic = (categoriesQuery.data || []).map((c) => c.value);
|
||||
// Ensure unique and stable order - 'all' should be last to match server response
|
||||
const set = new Set<string>(['promoted', ...dynamic, 'all']);
|
||||
return Array.from(set);
|
||||
}, [categoriesQuery.data]);
|
||||
|
||||
const getTabIndex = useCallback(
|
||||
(tab: string): number => {
|
||||
const idx = orderedTabs.indexOf(tab);
|
||||
return idx >= 0 ? idx : 0;
|
||||
},
|
||||
[orderedTabs],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle category tab selection changes with directional animation
|
||||
*/
|
||||
const handleTabChange = (tabValue: string) => {
|
||||
if (tabValue === activeTab || isTransitioning) {
|
||||
// Ignore redundant or rapid clicks during transition
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = getTabIndex(displayCategory);
|
||||
const newIndex = getTabIndex(tabValue);
|
||||
const direction: Direction = newIndex > currentIndex ? 'right' : 'left';
|
||||
|
||||
setAnimationDirection(direction);
|
||||
setNextCategory(tabValue);
|
||||
setIsTransitioning(true);
|
||||
|
||||
// Update URL immediately, preserving current search params
|
||||
const currentSearchParams = searchParams.toString();
|
||||
const searchParamsStr = currentSearchParams ? `?${currentSearchParams}` : '';
|
||||
|
||||
// Navigate to the selected category
|
||||
if (tabValue === 'promoted') {
|
||||
navigate(`/agents${searchParamsStr}`);
|
||||
} else {
|
||||
navigate(`/agents/${tabValue}${searchParamsStr}`);
|
||||
}
|
||||
|
||||
// Complete transition after 300ms
|
||||
window.setTimeout(() => {
|
||||
setDisplayCategory(tabValue);
|
||||
setNextCategory(null);
|
||||
setIsTransitioning(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync animation when URL changes externally (back/forward or deep links)
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!didInitRef.current) {
|
||||
// First render: do not animate; just set display to current active tab
|
||||
didInitRef.current = true;
|
||||
setDisplayCategory(activeTab);
|
||||
return;
|
||||
}
|
||||
if (isTransitioning || activeTab === displayCategory) {
|
||||
return;
|
||||
}
|
||||
// Compute direction vs current displayCategory and animate
|
||||
const currentIndex = getTabIndex(displayCategory);
|
||||
const newIndex = getTabIndex(activeTab);
|
||||
const direction: Direction = newIndex > currentIndex ? 'right' : 'left';
|
||||
|
||||
setAnimationDirection(direction);
|
||||
setNextCategory(activeTab);
|
||||
setIsTransitioning(true);
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setDisplayCategory(activeTab);
|
||||
setNextCategory(null);
|
||||
setIsTransitioning(false);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [activeTab, displayCategory, isTransitioning, getTabIndex]);
|
||||
|
||||
// No longer needed with keyframes
|
||||
|
||||
/**
|
||||
* Handle search query changes
|
||||
*
|
||||
|
|
@ -207,7 +299,10 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
|||
>
|
||||
<main className="flex h-full flex-col overflow-hidden" role="main">
|
||||
{/* Scrollable container */}
|
||||
<div className="scrollbar-gutter-stable flex h-full flex-col overflow-y-auto overflow-x-hidden">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="scrollbar-gutter-stable flex h-full flex-col overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
{/* Simplified header for agents marketplace - only show nav controls when needed */}
|
||||
{!isSmallScreen && (
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between bg-surface-secondary p-2 font-semibold text-text-primary md:h-14">
|
||||
|
|
@ -276,63 +371,158 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
|||
|
||||
{/* Scrollable content area */}
|
||||
<div className="container mx-auto max-w-4xl px-4 pb-8">
|
||||
{/* Category header - only show when not searching */}
|
||||
{!searchQuery && (
|
||||
<div className="mb-6 mt-6">
|
||||
{(() => {
|
||||
// Get category data for display
|
||||
const getCategoryData = () => {
|
||||
if (activeTab === 'promoted') {
|
||||
return {
|
||||
name: localize('com_agents_top_picks'),
|
||||
description: localize('com_agents_recommended'),
|
||||
{/* Two-pane animated container wrapping category header + grid */}
|
||||
<div className="relative overflow-hidden">
|
||||
{/* Current content pane */}
|
||||
<div
|
||||
className={cn(
|
||||
isTransitioning &&
|
||||
(animationDirection === 'right'
|
||||
? 'motion-safe:animate-slide-out-left'
|
||||
: 'motion-safe:animate-slide-out-right'),
|
||||
)}
|
||||
key={`pane-current-${displayCategory}`}
|
||||
>
|
||||
{/* Category header - only show when not searching */}
|
||||
{!searchQuery && (
|
||||
<div className="mb-6 mt-6">
|
||||
{(() => {
|
||||
// Get category data for display
|
||||
const getCategoryData = () => {
|
||||
if (displayCategory === 'promoted') {
|
||||
return {
|
||||
name: localize('com_agents_top_picks'),
|
||||
description: localize('com_agents_recommended'),
|
||||
};
|
||||
}
|
||||
if (displayCategory === 'all') {
|
||||
return {
|
||||
name: 'All Agents',
|
||||
description: 'Browse all shared agents across all categories',
|
||||
};
|
||||
}
|
||||
|
||||
// Find the category in the API data
|
||||
const categoryData = categoriesQuery.data?.find(
|
||||
(cat) => cat.value === displayCategory,
|
||||
);
|
||||
if (categoryData) {
|
||||
return {
|
||||
name: categoryData.label,
|
||||
description: categoryData.description || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for unknown categories
|
||||
return {
|
||||
name:
|
||||
displayCategory.charAt(0).toUpperCase() +
|
||||
displayCategory.slice(1),
|
||||
description: '',
|
||||
};
|
||||
};
|
||||
}
|
||||
if (activeTab === 'all') {
|
||||
return {
|
||||
name: 'All Agents',
|
||||
description: 'Browse all shared agents across all categories',
|
||||
};
|
||||
}
|
||||
|
||||
// Find the category in the API data
|
||||
const categoryData = categoriesQuery.data?.find(
|
||||
(cat) => cat.value === activeTab,
|
||||
);
|
||||
if (categoryData) {
|
||||
return {
|
||||
name: categoryData.label,
|
||||
description: categoryData.description || '',
|
||||
};
|
||||
}
|
||||
const { name, description } = getCategoryData();
|
||||
|
||||
// Fallback for unknown categories
|
||||
return {
|
||||
name: activeTab.charAt(0).toUpperCase() + activeTab.slice(1),
|
||||
description: '',
|
||||
};
|
||||
};
|
||||
return (
|
||||
<div className="text-left">
|
||||
<h2 className="text-2xl font-bold text-text-primary">{name}</h2>
|
||||
{description && (
|
||||
<p className="mt-2 text-text-secondary">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
const { name, description } = getCategoryData();
|
||||
|
||||
return (
|
||||
<div className="text-left">
|
||||
<h2 className="text-2xl font-bold text-text-primary">{name}</h2>
|
||||
{description && (
|
||||
<p className="mt-2 text-text-secondary">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Agent grid */}
|
||||
<AgentGrid
|
||||
key={`grid-${displayCategory}`}
|
||||
category={displayCategory}
|
||||
searchQuery={searchQuery}
|
||||
onSelectAgent={handleAgentSelect}
|
||||
scrollElement={scrollContainerRef.current}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent grid */}
|
||||
<AgentGrid
|
||||
category={activeTab}
|
||||
searchQuery={searchQuery}
|
||||
onSelectAgent={handleAgentSelect}
|
||||
/>
|
||||
{/* Next content pane, only during transition */}
|
||||
{isTransitioning && nextCategory && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0',
|
||||
animationDirection === 'right'
|
||||
? 'motion-safe:animate-slide-in-right'
|
||||
: 'motion-safe:animate-slide-in-left',
|
||||
)}
|
||||
key={`pane-next-${nextCategory}-${animationDirection}`}
|
||||
>
|
||||
{/* Category header - only show when not searching */}
|
||||
{!searchQuery && (
|
||||
<div className="mb-6 mt-6">
|
||||
{(() => {
|
||||
// Get category data for display
|
||||
const getCategoryData = () => {
|
||||
if (nextCategory === 'promoted') {
|
||||
return {
|
||||
name: localize('com_agents_top_picks'),
|
||||
description: localize('com_agents_recommended'),
|
||||
};
|
||||
}
|
||||
if (nextCategory === 'all') {
|
||||
return {
|
||||
name: 'All Agents',
|
||||
description: 'Browse all shared agents across all categories',
|
||||
};
|
||||
}
|
||||
|
||||
// Find the category in the API data
|
||||
const categoryData = categoriesQuery.data?.find(
|
||||
(cat) => cat.value === nextCategory,
|
||||
);
|
||||
if (categoryData) {
|
||||
return {
|
||||
name: categoryData.label,
|
||||
description: categoryData.description || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for unknown categories
|
||||
return {
|
||||
name:
|
||||
(nextCategory || '').charAt(0).toUpperCase() +
|
||||
(nextCategory || '').slice(1),
|
||||
description: '',
|
||||
};
|
||||
};
|
||||
|
||||
const { name, description } = getCategoryData();
|
||||
|
||||
return (
|
||||
<div className="text-left">
|
||||
<h2 className="text-2xl font-bold text-text-primary">{name}</h2>
|
||||
{description && (
|
||||
<p className="mt-2 text-text-secondary">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent grid */}
|
||||
<AgentGrid
|
||||
key={`grid-${nextCategory}`}
|
||||
category={nextCategory}
|
||||
searchQuery={searchQuery}
|
||||
onSelectAgent={handleAgentSelect}
|
||||
scrollElement={scrollContainerRef.current}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note: Using Tailwind keyframes for slide in/out animations */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent detail dialog */}
|
||||
|
|
|
|||
|
|
@ -73,33 +73,33 @@ const SearchBar: React.FC<SearchBarProps> = ({ value, onSearch, className = '' }
|
|||
value={searchTerm}
|
||||
onChange={handleChange}
|
||||
placeholder={localize('com_agents_search_placeholder')}
|
||||
className="h-14 rounded-2xl border-2 border-border-medium bg-transparent pl-12 pr-12 text-lg text-text-primary shadow-lg placeholder:text-text-tertiary focus:border-border-heavy focus:ring-0"
|
||||
className="h-14 rounded-2xl border-border-medium bg-transparent pl-12 pr-12 text-lg text-text-primary shadow-md transition-[border-color,box-shadow] duration-200 placeholder:text-text-secondary focus:border-border-heavy focus:shadow-lg focus:ring-0"
|
||||
aria-label={localize('com_agents_search_aria')}
|
||||
aria-describedby="search-instructions search-results-count"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
|
||||
{/* Search icon with proper accessibility */}
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-4" aria-hidden="true">
|
||||
<Search className="h-6 w-6 text-text-tertiary" />
|
||||
<Search className="size-5 text-text-secondary" />
|
||||
</div>
|
||||
|
||||
{/* Hidden instructions for screen readers */}
|
||||
<div id="search-instructions" className="sr-only">
|
||||
{localize('com_agents_search_instructions')}
|
||||
</div>
|
||||
|
||||
{/* Show clear button only when search has value - Google style */}
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="group absolute right-3 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-text-tertiary transition-colors duration-150 hover:bg-text-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
className="group absolute right-4 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
aria-label={localize('com_agents_clear_search')}
|
||||
title={localize('com_agents_clear_search')}
|
||||
>
|
||||
<X className="h-3 w-3 text-white group-hover:text-white" strokeWidth={2.5} />
|
||||
<X
|
||||
className="size-5 text-text-secondary transition-colors duration-200 group-hover:text-text-primary"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
348
client/src/components/Agents/VirtualizedAgentGrid.tsx
Normal file
348
client/src/components/Agents/VirtualizedAgentGrid.tsx
Normal file
|
|
@ -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<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;
|
||||
|
|
@ -48,7 +48,7 @@ describe('AgentCard', () => {
|
|||
|
||||
expect(screen.getByText('Test Agent')).toBeInTheDocument();
|
||||
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Created by')).toBeInTheDocument();
|
||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Support')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -152,7 +152,7 @@ describe('AgentCard', () => {
|
|||
|
||||
render(<AgentCard agent={agentWithAuthorName} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('Created by')).toBeInTheDocument();
|
||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -165,7 +165,7 @@ describe('AgentCard', () => {
|
|||
|
||||
render(<AgentCard agent={agentWithEmailOnly} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('Created by')).toBeInTheDocument();
|
||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||
expect(screen.getByText('contact@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -178,7 +178,7 @@ describe('AgentCard', () => {
|
|||
|
||||
render(<AgentCard agent={agentWithBoth} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('Created by')).toBeInTheDocument();
|
||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -195,7 +195,7 @@ describe('AgentCard', () => {
|
|||
|
||||
render(<AgentCard agent={agentWithNameAndEmail} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('Created by')).toBeInTheDocument();
|
||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||
expect(screen.queryByText('support@example.com')).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,13 +33,11 @@ jest.mock('~/hooks/useLocalize', () => () => (key: string, options?: any) => {
|
|||
com_agents_see_more: 'See more',
|
||||
com_agents_error_loading: 'Error loading agents',
|
||||
com_agents_error_searching: 'Error searching agents',
|
||||
com_agents_no_results: 'No agents found. Try another search term.',
|
||||
com_agents_none_in_category: 'No agents found in this category',
|
||||
com_agents_search_empty_heading: 'No results found',
|
||||
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;
|
||||
|
|
@ -250,8 +248,9 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
|
|||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Should show skeleton loading state
|
||||
expect(document.querySelector('.animate-pulse')).toBeInTheDocument();
|
||||
// Should show loading spinner
|
||||
const spinner = document.querySelector('.text-primary');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show empty state when no agents are available', () => {
|
||||
|
|
@ -312,7 +311,8 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
|
|||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Results for "automation"')).toBeInTheDocument();
|
||||
// The component doesn't show search result titles, just displays the filtered agents
|
||||
expect(screen.getByTestId('agent-card-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show empty search results message', () => {
|
||||
|
|
@ -338,29 +338,16 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
|
|||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('No results found')).toBeInTheDocument();
|
||||
expect(screen.getByText('No agents found. Try another search term.')).toBeInTheDocument();
|
||||
expect(screen.getByText('No agents available')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Load More Functionality', () => {
|
||||
it('should show "See more" button when hasNextPage is true', () => {
|
||||
const Wrapper = createWrapper();
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
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();
|
||||
|
|
@ -370,7 +357,44 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
|
|||
</Wrapper>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Wrapper>
|
||||
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Wrapper>
|
||||
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("You've reached the end of the results")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ describe('CategoryTabs', () => {
|
|||
);
|
||||
|
||||
const generalTab = screen.getByText('General').closest('button');
|
||||
expect(generalTab).toHaveClass('bg-surface-tertiary');
|
||||
expect(generalTab).toHaveClass('bg-surface-hover');
|
||||
|
||||
// Should have active underline
|
||||
const underline = generalTab?.querySelector('.absolute.bottom-0');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,275 @@
|
|||
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';
|
||||
import type * as t from 'librechat-data-provider';
|
||||
|
||||
// Mock react-virtualized for performance testing
|
||||
const mockRowRenderer = jest.fn();
|
||||
|
||||
jest.mock('react-virtualized', () => {
|
||||
const mockRowRendererRef = { current: jest.fn() };
|
||||
|
||||
return {
|
||||
AutoSizer: ({
|
||||
children,
|
||||
disableHeight,
|
||||
}: {
|
||||
children: (props: { width: number; height?: number }) => React.ReactNode;
|
||||
disableHeight?: boolean;
|
||||
}) => {
|
||||
if (disableHeight) {
|
||||
return children({ width: 1200 });
|
||||
}
|
||||
return children({ width: 1200, height: 800 });
|
||||
},
|
||||
List: ({
|
||||
rowRenderer,
|
||||
rowCount,
|
||||
autoHeight,
|
||||
height,
|
||||
width,
|
||||
rowHeight,
|
||||
overscanRowCount,
|
||||
scrollTop,
|
||||
isScrolling,
|
||||
onScroll,
|
||||
style,
|
||||
'aria-rowcount': ariaRowCount,
|
||||
'data-testid': dataTestId,
|
||||
'data-total-rows': dataTotalRows,
|
||||
}: {
|
||||
rowRenderer: any;
|
||||
rowCount: number;
|
||||
[key: string]: any;
|
||||
}) => {
|
||||
// Store the row renderer for testing
|
||||
if (typeof rowRenderer === 'function') {
|
||||
mockRowRendererRef.current = rowRenderer;
|
||||
mockRowRenderer.mockImplementation(rowRenderer);
|
||||
}
|
||||
// Only render visible rows to simulate virtualization
|
||||
const visibleRows = Math.min(10, rowCount); // Simulate 10 visible rows
|
||||
return (
|
||||
<div
|
||||
data-testid={dataTestId || 'virtual-list'}
|
||||
data-total-rows={dataTotalRows || rowCount}
|
||||
aria-rowcount={ariaRowCount}
|
||||
style={style}
|
||||
>
|
||||
{Array.from({ length: visibleRows }, (_, index) =>
|
||||
rowRenderer({
|
||||
index,
|
||||
key: `row-${index}`,
|
||||
style: { height: 184 },
|
||||
parent: { props: { width: width || 1200 } },
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
WindowScroller: ({
|
||||
children,
|
||||
scrollElement,
|
||||
}: {
|
||||
children: (props: any) => React.ReactNode;
|
||||
scrollElement?: HTMLElement | null;
|
||||
}) => {
|
||||
return children({
|
||||
height: 800,
|
||||
isScrolling: false,
|
||||
registerChild: (ref: any) => {},
|
||||
onChildScroll: () => {},
|
||||
scrollTop: 0,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Generate large dataset for performance testing
|
||||
const generateLargeDataset = (count: number) => {
|
||||
const agents: Partial<t.Agent>[] = [];
|
||||
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,
|
||||
});
|
||||
|
||||
// Mock must be hoisted before imports
|
||||
jest.mock('~/data-provider/Agents', () => ({
|
||||
useMarketplaceAgentsInfiniteQuery: jest.fn(),
|
||||
}));
|
||||
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('../AgentCard', () => {
|
||||
return function MockAgentCard({ agent }: { agent: any }) {
|
||||
return (
|
||||
<div data-testid={`agent-card-${agent.id}`} style={{ height: '160px' }}>
|
||||
<h3>{agent.name}</h3>
|
||||
<p>{agent.description}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
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);
|
||||
const useMarketplaceAgentsInfiniteQuery =
|
||||
jest.requireMock('~/data-provider/Agents').useMarketplaceAgentsInfiniteQuery;
|
||||
useMarketplaceAgentsInfiniteQuery.mockReturnValue(mockQuery);
|
||||
|
||||
// Clear previous mock calls
|
||||
mockRowRenderer.mockClear();
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<VirtualizedAgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
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(600); // Should render in less than 600ms
|
||||
|
||||
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', () => {
|
||||
// Reset the mock before testing
|
||||
mockRowRenderer.mockClear();
|
||||
|
||||
renderComponent(1000);
|
||||
|
||||
// Check that virtual list was rendered
|
||||
const virtualList = screen.getByTestId('virtual-list');
|
||||
expect(virtualList).toBeInTheDocument();
|
||||
|
||||
// With virtualization, we should only render visible rows
|
||||
// Our mock renders 10 visible rows max
|
||||
const renderedCards = screen.getAllByTestId(/agent-card-/);
|
||||
expect(renderedCards.length).toBeLessThanOrEqual(20); // At most 10 rows * 2 cards per row
|
||||
expect(renderedCards.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(30);
|
||||
expect(Math.abs(memory1000 - memory5000)).toBeLessThan(30);
|
||||
|
||||
console.log(
|
||||
`Memory usage: 100 agents=${memory100}, 1000 agents=${memory1000}, 5000 agents=${memory5000}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
248
client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx
Normal file
248
client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
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,
|
||||
disableHeight,
|
||||
}: {
|
||||
children: (props: { width: number; height?: number }) => React.ReactNode;
|
||||
disableHeight?: boolean;
|
||||
}) => {
|
||||
if (disableHeight) {
|
||||
return children({ width: 800 });
|
||||
}
|
||||
return children({ width: 800, height: 600 });
|
||||
},
|
||||
List: ({
|
||||
rowRenderer,
|
||||
rowCount,
|
||||
width,
|
||||
style,
|
||||
'aria-rowcount': ariaRowCount,
|
||||
'data-testid': dataTestId,
|
||||
'data-total-rows': dataTotalRows,
|
||||
}: {
|
||||
rowRenderer: any;
|
||||
rowCount: number;
|
||||
autoHeight?: boolean;
|
||||
height?: number;
|
||||
width?: number;
|
||||
rowHeight?: number;
|
||||
overscanRowCount?: number;
|
||||
scrollTop?: number;
|
||||
isScrolling?: boolean;
|
||||
onScroll?: any;
|
||||
style?: any;
|
||||
'aria-rowcount'?: number;
|
||||
'data-testid'?: string;
|
||||
'data-total-rows'?: number;
|
||||
}) => (
|
||||
<div
|
||||
data-testid={dataTestId || 'virtual-list'}
|
||||
aria-rowcount={ariaRowCount}
|
||||
data-total-rows={dataTotalRows}
|
||||
style={style}
|
||||
>
|
||||
{Array.from({ length: Math.min(rowCount, 5) }, (_, index) =>
|
||||
rowRenderer({
|
||||
index,
|
||||
key: `row-${index}`,
|
||||
style: {},
|
||||
parent: { props: { width: width || 800 } },
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
WindowScroller: ({
|
||||
children,
|
||||
}: {
|
||||
children: (props: any) => React.ReactNode;
|
||||
scrollElement?: HTMLElement | null;
|
||||
}) => {
|
||||
return children({
|
||||
height: 600,
|
||||
isScrolling: false,
|
||||
registerChild: (_ref: any) => {},
|
||||
onChildScroll: () => {},
|
||||
scrollTop: 0,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// 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('../AgentCard', () => {
|
||||
return function MockAgentCard({ agent, onClick }: { agent: t.Agent; onClick: () => void }) {
|
||||
return (
|
||||
<div data-testid={`agent-card-${agent.id}`} onClick={onClick}>
|
||||
<h3>{agent.name}</h3>
|
||||
<p>{agent.description}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<VirtualizedAgentGrid {...defaultProps} {...props} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
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
|
||||
const spinner = document.querySelector('.spinner');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
expect(spinner).toHaveClass('h-8 w-8 text-primary');
|
||||
});
|
||||
|
||||
it('has proper accessibility attributes', async () => {
|
||||
// Reset the mock to ensure we have data
|
||||
const useMarketplaceAgentsInfiniteQuery =
|
||||
jest.requireMock('~/data-provider/Agents').useMarketplaceAgentsInfiniteQuery;
|
||||
useMarketplaceAgentsInfiniteQuery.mockImplementation(() => mockInfiniteQuery);
|
||||
|
||||
renderComponent({ category: 'productivity' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('virtual-list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const gridContainer = screen.getByRole('grid');
|
||||
expect(gridContainer).toHaveAttribute('aria-label');
|
||||
expect(gridContainer.getAttribute('aria-label')).toContain('2');
|
||||
expect(gridContainer.getAttribute('aria-label')).toContain('Productivity');
|
||||
|
||||
const tabpanel = screen.getByRole('tabpanel');
|
||||
expect(tabpanel).toHaveAttribute('id', 'category-panel-productivity');
|
||||
expect(tabpanel).toHaveAttribute('aria-labelledby', 'category-tab-productivity');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue