WIP: pre-granular-permissions commit

feat: Add category and support contact fields to Agent schema and UI components

Revert "feat: Add category and support contact fields to Agent schema and UI components"

This reverts commit c43a52b4c9.

Fix: Update import for renderHook in useAgentCategories.spec.tsx

fix: Update icon rendering in AgentCategoryDisplay tests to use empty spans

refactor: Improve category synchronization logic and clean up AgentConfig component

refactor: Remove unused UI flow translations from translation.json

feat: agent marketplace features
This commit is contained in:
Danny Avila 2025-06-23 10:22:27 -04:00
parent 01e9b196bc
commit 6c9a29b6cf
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
55 changed files with 6280 additions and 17 deletions

View file

@ -5,6 +5,8 @@ const { SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
require('librechat-data-provider').Constants;
const { CONFIG_STORE, STARTUP_CONFIG } = require('librechat-data-provider').CacheKeys;
// Default category value for new agents
const AgentCategory = require('./AgentCategory');
const {
getProjectByName,
addAgentIdsToProject,
@ -13,6 +15,82 @@ const {
} = require('./Project');
const { getCachedTools } = require('~/server/services/Config');
const getLogStores = require('~/cache/getLogStores');
// Category values are now imported from shared constants
// Add category field to the Agent schema if it doesn't already exist
if (!agentSchema.paths.category) {
agentSchema.add({
category: {
type: String,
trim: true,
validate: {
validator: async function (value) {
if (!value) return true; // Allow empty values (will use default)
// Check if category exists in database
const validCategories = await AgentCategory.getValidCategoryValues();
return validCategories.includes(value);
},
message: function (props) {
return `"${props.value}" is not a valid agent category. Please check available categories.`;
},
},
index: true,
default: 'general',
},
});
}
// Add support_contact field to the Agent schema if it doesn't already exist
if (!agentSchema.paths.support_contact) {
agentSchema.add({
support_contact: {
type: Object,
default: {},
name: {
type: String,
minlength: [3, 'Support contact name must be at least 3 characters.'],
trim: true,
},
email: {
type: String,
match: [
/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/,
'Please enter a valid email address.',
],
trim: true,
},
},
});
}
// Add promotion field to the Agent schema if it doesn't already exist
if (!agentSchema.paths.is_promoted) {
agentSchema.add({
is_promoted: {
type: Boolean,
default: false,
index: true, // Index for efficient promoted agent queries
},
});
}
// Add additional indexes for marketplace functionality
agentSchema.index({ projectIds: 1, is_promoted: 1, updatedAt: -1 }); // Optimize promoted agents query
agentSchema.index({ category: 1, projectIds: 1, updatedAt: -1 }); // Optimize category filtering
agentSchema.index({ projectIds: 1, category: 1 }); // Optimize aggregation pipeline
// Text indexes for search functionality
agentSchema.index(
{ name: 'text', description: 'text' },
{
weights: {
name: 3, // Name matches are 3x more important than description matches
description: 1,
},
},
);
const { getActions } = require('./Action');
const { Agent } = require('~/db/models');
@ -34,6 +112,7 @@ const createAgent = async (agentData) => {
updatedAt: timestamp,
},
],
category: agentData.category || 'general',
};
return (await Agent.create(initialAgentData)).toObject();
};
@ -491,6 +570,7 @@ const getListAgents = async (searchParameter) => {
projectIds: 1,
description: 1,
isCollaborative: 1,
category: 1,
}).lean()
).map((agent) => {
if (agent.author?.toString() !== author) {

125
api/models/AgentCategory.js Normal file
View file

@ -0,0 +1,125 @@
const mongoose = require('mongoose');
/**
* AgentCategory Schema - Dynamic agent category management
* Focused implementation for core features only
*/
const agentCategorySchema = new mongoose.Schema(
{
// Unique identifier for the category (e.g., 'general', 'hr', 'finance')
value: {
type: String,
required: true,
unique: true,
trim: true,
lowercase: true,
index: true,
},
// Display label for the category
label: {
type: String,
required: true,
trim: true,
},
// Description of the category
description: {
type: String,
trim: true,
default: '',
},
// Display order for sorting categories
order: {
type: Number,
default: 0,
index: true,
},
// Whether the category is active and should be displayed
isActive: {
type: Boolean,
default: true,
index: true,
},
},
{
timestamps: true,
}
);
// Indexes for performance
agentCategorySchema.index({ isActive: 1, order: 1 });
/**
* Get all active categories sorted by order
* @returns {Promise<AgentCategory[]>} Array of active categories
*/
agentCategorySchema.statics.getActiveCategories = function() {
return this.find({ isActive: true })
.sort({ order: 1, label: 1 })
.lean();
};
/**
* Get categories with agent counts
* @returns {Promise<AgentCategory[]>} Categories with agent counts
*/
agentCategorySchema.statics.getCategoriesWithCounts = async function() {
const Agent = mongoose.model('agent');
// Aggregate to get agent counts per category
const categoryCounts = await Agent.aggregate([
{ $match: { category: { $exists: true, $ne: null } } },
{ $group: { _id: '$category', count: { $sum: 1 } } },
]);
// Create a map for quick lookup
const countMap = new Map(categoryCounts.map(c => [c._id, c.count]));
// Get all active categories and add counts
const categories = await this.getActiveCategories();
return categories.map(category => ({
...category,
agentCount: countMap.get(category.value) || 0,
}));
};
/**
* Get valid category values for Agent model validation
* @returns {Promise<string[]>} Array of valid category values
*/
agentCategorySchema.statics.getValidCategoryValues = function() {
return this.find({ isActive: true })
.distinct('value')
.lean();
};
/**
* Seed initial categories from existing constants
*/
agentCategorySchema.statics.seedCategories = async function(categories) {
const operations = categories.map((category, index) => ({
updateOne: {
filter: { value: category.value },
update: {
$setOnInsert: {
value: category.value,
label: category.label || category.value,
description: category.description || '',
order: category.order || index,
isActive: true,
},
},
upsert: true,
},
}));
return this.bulkWrite(operations);
};
const AgentCategory = mongoose.model('AgentCategory', agentCategorySchema);
module.exports = AgentCategory;

View file

@ -0,0 +1,255 @@
const AgentCategory = require('~/models/AgentCategory');
const mongoose = require('mongoose');
const { logger } = require('~/config');
// Get the Agent model
const Agent = mongoose.model('agent');
// Default page size for agent browsing
const DEFAULT_PAGE_SIZE = 6;
/**
* Common pagination utility for agent queries
*
* @param {Object} filter - MongoDB filter object
* @param {number} page - Page number (1-based)
* @param {number} limit - Items per page
* @returns {Promise<Object>} Paginated results with agents and pagination info
*/
const paginateAgents = async (filter, page = 1, limit = DEFAULT_PAGE_SIZE) => {
const skip = (page - 1) * limit;
// Get total count for pagination
const total = await Agent.countDocuments(filter);
// Get agents with pagination
const agents = await Agent.find(filter)
.select('id name description avatar category support_contact authorName')
.sort({ updatedAt: -1 })
.skip(skip)
.limit(limit)
.lean();
// Calculate if there are more agents to load
const hasMore = total > page * limit;
return {
agents,
pagination: {
current: page,
hasMore,
total,
},
};
};
/**
* Get promoted/top picks agents with pagination
* Can also return all agents when showAll=true parameter is provided
*
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
const getPromotedAgents = async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || DEFAULT_PAGE_SIZE;
// Check if this is a request for "all" agents via query parameter
const showAllAgents = req.query.showAll === 'true';
// Base filter for shared agents only
const filter = {
projectIds: { $exists: true, $ne: [] }, // Only get shared agents
};
// Only add promoted filter if not requesting all agents
if (!showAllAgents) {
filter.is_promoted = true; // Only get promoted agents
}
const result = await paginateAgents(filter, page, limit);
res.status(200).json(result);
} catch (error) {
logger.error('[/Agents/Marketplace] Error fetching promoted agents:', error);
res.status(500).json({
error: 'Failed to fetch promoted agents',
userMessage: 'Unable to load agents. Please try refreshing the page.',
suggestion: 'Try refreshing the page or check your network connection',
});
}
};
/**
* Get agents by category with pagination
*
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
const getAgentsByCategory = async (req, res) => {
try {
const { category } = req.params;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || DEFAULT_PAGE_SIZE;
const filter = {
category,
projectIds: { $exists: true, $ne: [] }, // Only get shared agents
};
const result = await paginateAgents(filter, page, limit);
// Get category description from database
const categoryDoc = await AgentCategory.findOne({ value: category, isActive: true });
const categoryInfo = {
name: category,
description: categoryDoc?.description || '',
total: result.pagination.total,
};
res.status(200).json({
...result,
category: categoryInfo,
});
} catch (error) {
logger.error(
`[/Agents/Marketplace] Error fetching agents for category ${req.params.category}:`,
error,
);
res.status(500).json({
error: 'Failed to fetch agents by category',
userMessage: `Unable to load agents for this category. Please try a different category.`,
suggestion: 'Try selecting a different category or refresh the page',
});
}
};
/**
* Search agents with filters
*
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
const searchAgents = async (req, res) => {
try {
const { q, category } = req.query;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || DEFAULT_PAGE_SIZE;
if (!q || q.trim() === '') {
return res.status(400).json({
error: 'Search query is required',
userMessage: 'Please enter a search term to find agents',
suggestion: 'Enter a search term to find agents by name or description',
});
}
// Build search filter
const filter = {
projectIds: { $exists: true, $ne: [] }, // Only get shared agents
$or: [
{ name: { $regex: q, $options: 'i' } }, // Case-insensitive name search
{ description: { $regex: q, $options: 'i' } },
],
};
// Add category filter if provided
if (category && category !== 'all') {
filter.category = category;
}
const result = await paginateAgents(filter, page, limit);
res.status(200).json({
...result,
query: q,
});
} catch (error) {
logger.error('[/Agents/Marketplace] Error searching agents:', error);
res.status(500).json({
error: 'Failed to search agents',
userMessage: 'Search is temporarily unavailable. Please try again.',
suggestion: 'Try a different search term or check your network connection',
});
}
};
/**
* Get all agent categories with counts
*
* @param {Object} _req - Express request object (unused)
* @param {Object} res - Express response object
*/
const getAgentCategories = async (_req, res) => {
try {
// Get categories with agent counts from database
const categories = await AgentCategory.getCategoriesWithCounts();
// Get count of promoted agents for Top Picks
const promotedCount = await Agent.countDocuments({
projectIds: { $exists: true, $ne: [] },
is_promoted: true,
});
// Convert to marketplace format (TCategory structure)
const formattedCategories = categories.map((category) => ({
value: category.value,
label: category.label,
count: category.agentCount,
description: category.description,
}));
// Add promoted category if agents exist
if (promotedCount > 0) {
formattedCategories.unshift({
value: 'promoted',
label: 'Promoted',
count: promotedCount,
description: 'Our recommended agents',
});
}
// Get total count of all shared agents for "All" category
const totalAgents = await Agent.countDocuments({
projectIds: { $exists: true, $ne: [] },
});
// Add "All" category at the end
formattedCategories.push({
value: 'all',
label: 'All',
count: totalAgents,
description: 'All available agents',
});
res.status(200).json(formattedCategories);
} catch (error) {
logger.error('[/Agents/Marketplace] Error fetching agent categories:', error);
res.status(500).json({
error: 'Failed to fetch agent categories',
userMessage: 'Unable to load categories. Please refresh the page.',
suggestion: 'Try refreshing the page or check your network connection',
});
}
};
/**
* Get all agents with pagination (for "all" category)
* This is an alias for getPromotedAgents with showAll=true for backwards compatibility
*
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
const getAllAgents = async (req, res) => {
// Set showAll parameter and delegate to getPromotedAgents
req.query.showAll = 'true';
return getPromotedAgents(req, res);
};
module.exports = {
getPromotedAgents,
getAgentsByCategory,
searchAgents,
getAgentCategories,
getAllAgents,
};

View file

@ -10,6 +10,7 @@ const {
const { isEnabled } = require('~/server/utils');
const { v1 } = require('./v1');
const chat = require('./chat');
const marketplace = require('./marketplace');
const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
@ -37,4 +38,7 @@ if (isEnabled(LIMIT_MESSAGE_USER)) {
chatRouter.use('/', chat);
router.use('/chat', chatRouter);
// Add marketplace routes
router.use('/marketplace', marketplace);
module.exports = router;

View file

@ -0,0 +1,46 @@
const express = require('express');
const { requireJwtAuth, checkBan, generateCheckAccess } = require('~/server/middleware');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const marketplace = require('~/server/controllers/agents/marketplace');
const router = express.Router();
// Apply middleware for authentication and authorization
router.use(requireJwtAuth);
router.use(checkBan);
// Check if user has permission to use agents
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
router.use(checkAgentAccess);
/**
* Get all agent categories with counts
* @route GET /agents/marketplace/categories
*/
router.get('/categories', marketplace.getAgentCategories);
/**
* Get promoted/top picks agents with pagination
* @route GET /agents/marketplace/promoted
*/
router.get('/promoted', marketplace.getPromotedAgents);
/**
* Get all agents with pagination (for "all" category)
* @route GET /agents/marketplace/all
*/
router.get('/all', marketplace.getAllAgents);
/**
* Search agents with filters
* @route GET /agents/marketplace/search
*/
router.get('/search', marketplace.searchAgents);
/**
* Get agents by category with pagination
* @route GET /agents/marketplace/category/:category
*/
router.get('/category/:category', marketplace.getAgentsByCategory);
module.exports = router;

View file

@ -17,6 +17,11 @@ export type TAgentCapabilities = {
[AgentCapabilities.hide_sequential_outputs]?: boolean;
};
export type SupportContact = {
name?: string;
email?: string;
};
export type AgentForm = {
agent?: TAgentOption;
id: string;
@ -30,4 +35,5 @@ export type AgentForm = {
agent_ids?: string[];
[AgentCapabilities.artifacts]?: ArtifactModes | string;
recursion_limit?: number;
support_contact?: SupportContact;
} & TAgentCapabilities;

View file

@ -16,6 +16,7 @@ const defaultInterface = getConfigDefaults().interface;
export default function Header() {
const { data: startupConfig } = useGetStartupConfig();
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
const interfaceConfig = useMemo(
() => startupConfig?.interface ?? defaultInterface,
[startupConfig],

View file

@ -17,7 +17,7 @@ export default function OpenSidebar({
variant="outline"
data-testid="open-sidebar-button"
aria-label={localize('com_nav_open_sidebar')}
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover"
onClick={() =>
setNavVisible((prev) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));

View file

@ -1,13 +1,21 @@
import React, { useCallback } from 'react';
import React, { useCallback, useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys, Constants } from 'librechat-data-provider';
import {
QueryKeys,
Constants,
EModelEndpoint,
PermissionTypes,
Permissions,
} from 'librechat-data-provider';
import type { TMessage, TStartupConfig } from 'librechat-data-provider';
import { NewChatIcon, MobileSidebar, Sidebar } from '~/components/svg';
import { getDefaultModelSpec, getModelSpecPreset } from '~/utils';
import { TooltipAnchor, Button } from '~/components/ui';
import { useLocalize, useNewConvo } from '~/hooks';
import { useLocalize, useNewConvo, useHasAccess } from '~/hooks';
import { AuthContext } from '~/hooks/AuthContext';
import { LayoutGrid } from 'lucide-react';
import store from '~/store';
export default function NewChat({
@ -29,6 +37,12 @@ export default function NewChat({
const navigate = useNavigate();
const localize = useLocalize();
const { conversation } = store.useCreateConversationAtom(index);
const endpointsConfig = useRecoilValue(store.endpointsConfig);
const authContext = useContext(AuthContext);
const hasAccessToAgents = useHasAccess({
permissionType: PermissionTypes.AGENTS,
permission: Permissions.USE,
});
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = useCallback(
(e) => {
@ -50,6 +64,22 @@ export default function NewChat({
[queryClient, conversation, newConvo, navigate, toggleNav, isSmallScreen],
);
const handleAgentMarketplace = useCallback(() => {
navigate('/agents');
if (isSmallScreen) {
toggleNav();
}
}, [navigate, isSmallScreen, toggleNav]);
// Check if auth is ready (avoid race conditions)
const authReady =
authContext?.isAuthenticated !== undefined &&
(authContext?.isAuthenticated === false || authContext?.user !== undefined);
// Show agent marketplace when auth is ready and user has access
// Note: endpointsConfig[agents] is null, but we can still show the marketplace
const showAgentMarketplace = authReady && hasAccessToAgents;
return (
<>
<div className="flex items-center justify-between py-[2px] md:py-2">
@ -88,6 +118,29 @@ export default function NewChat({
/>
</div>
</div>
{/* Agent Marketplace button - separate row like ChatGPT */}
{showAgentMarketplace && (
<div className="flex px-2 pb-4 pt-2 md:px-3">
<TooltipAnchor
description={localize('com_nav_agents_marketplace')}
render={
<Button
variant="outline"
data-testid="nav-agents-marketplace-button"
aria-label={localize('com_nav_agents_marketplace')}
className="flex w-full items-center justify-start gap-3 rounded-xl border-none bg-transparent p-3 text-left hover:bg-surface-hover"
onClick={handleAgentMarketplace}
>
<LayoutGrid className="h-5 w-5 flex-shrink-0" />
<span className="truncate text-base font-medium">
{localize('com_nav_agents_marketplace')}
</span>
</Button>
}
/>
</div>
)}
{subHeaders != null ? subHeaders : null}
</>
);

View file

@ -9,6 +9,10 @@ import {
PlaneTakeoffIcon,
GraduationCapIcon,
TerminalSquareIcon,
// NEW: Add these for agent categories
Users as UsersIcon,
Beaker as BeakerIcon,
Settings as SettingsIcon,
} from 'lucide-react';
import { cn } from '~/utils';
@ -22,6 +26,13 @@ const categoryIconMap: Record<string, React.ElementType> = {
code: TerminalSquareIcon,
travel: PlaneTakeoffIcon,
teach_or_explain: GraduationCapIcon,
// NEW: Agent categories
general: BoxIcon,
hr: UsersIcon,
rd: BeakerIcon,
it: TerminalSquareIcon,
sales: LineChartIcon,
aftersales: SettingsIcon,
};
const categoryColorMap: Record<string, string> = {
@ -34,6 +45,13 @@ const categoryColorMap: Record<string, string> = {
finance: 'text-orange-400',
roleplay: 'text-orange-400',
teach_or_explain: 'text-blue-300',
// NEW: Agent categories
general: 'text-blue-500',
hr: 'text-green-500',
rd: 'text-purple-500',
it: 'text-red-500',
sales: 'text-orange-500',
aftersales: 'text-yellow-500',
};
export default function CategoryIcon({

View file

@ -0,0 +1,93 @@
import React from 'react';
import type t from 'librechat-data-provider';
import useLocalize from '~/hooks/useLocalize';
import { renderAgentAvatar, getContactDisplayName } from '~/utils/agents';
import { cn } from '~/utils';
interface AgentCardProps {
agent: t.Agent; // The agent data to display
onClick: () => void; // Callback when card is clicked
className?: string; // Additional CSS classes
}
/**
* Card component to display agent information
*/
const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' }) => {
const localize = useLocalize();
return (
<div
className={cn(
'group relative flex overflow-hidden rounded-2xl',
'cursor-pointer transition-colors duration-200',
'aspect-[5/2.5] w-full',
'bg-gray-50 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600',
className,
)}
onClick={onClick}
aria-label={localize('com_agents_agent_card_label', {
name: agent.name,
description: agent.description || localize('com_agents_no_description'),
})}
aria-describedby={`agent-${agent.id}-description`}
tabIndex={0}
role="button"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}}
>
<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' })}
</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-gray-900 dark:text-white sm:mb-2 sm:text-lg">
{agent.name}
</h3>
{/* Agent description - responsive text sizing and spacing */}
<p
id={`agent-${agent.id}-description`}
className={cn(
'mb-1 line-clamp-2 text-xs leading-relaxed text-gray-600 dark:text-gray-300',
'sm:mb-2 sm:text-sm',
)}
aria-label={`Description: ${agent.description || localize('com_agents_no_description')}`}
>
{agent.description || (
<span className="italic text-gray-400">{localize('com_agents_no_description')}</span>
)}
</p>
{/* Owner info - responsive text sizing */}
{(() => {
const displayName = getContactDisplayName(agent);
if (displayName) {
return (
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 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>
);
};
export default AgentCard;

View file

@ -0,0 +1,62 @@
import React from 'react';
import { useAgentCategories } from '~/hooks/Agents';
import { cn } from '~/utils';
interface AgentCategoryDisplayProps {
category?: string;
className?: string;
showIcon?: boolean;
iconClassName?: string;
showEmptyFallback?: boolean;
}
/**
* Component to display an agent category with proper translation
*
* @param category - The category value (e.g., "general", "hr", etc.)
* @param className - Optional className for the container
* @param showIcon - Whether to show the category icon
* @param iconClassName - Optional className for the icon
* @param showEmptyFallback - Whether to show a fallback for empty categories
*/
const AgentCategoryDisplay: React.FC<AgentCategoryDisplayProps> = ({
category,
className = '',
showIcon = true,
iconClassName = 'h-4 w-4 mr-2',
showEmptyFallback = false,
}) => {
const { categories, emptyCategory } = useAgentCategories();
// Find the category in our processed categories list
const categoryItem = categories.find((c) => c.value === category);
// Handle empty string case differently than undefined/null
if (category === '') {
if (!showEmptyFallback) {
return null;
}
// Show the empty category placeholder
return (
<div className={cn('flex items-center text-gray-400', className)}>
<span>{emptyCategory.label}</span>
</div>
);
}
// No category or unknown category
if (!category || !categoryItem) {
return null;
}
return (
<div className={cn('flex items-center', className)}>
{showIcon && categoryItem.icon && (
<span className={cn('flex-shrink-0', iconClassName)}>{categoryItem.icon}</span>
)}
<span>{categoryItem.label}</span>
</div>
);
};
export default AgentCategoryDisplay;

View file

@ -0,0 +1,95 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
useFormContext,
Controller,
useWatch,
ControllerRenderProps,
FieldValues,
FieldPath,
} from 'react-hook-form';
import ControlCombobox from '~/components/ui/ControlCombobox';
import { useAgentCategories } from '~/hooks/Agents';
import { OptionWithIcon } from '~/common/types';
import { cn } from '~/utils';
/**
* Custom hook to handle category synchronization
*/
const useCategorySync = (agent_id: string | null) => {
const [handled, setHandled] = useState(false);
return {
syncCategory: (field: ControllerRenderProps<FieldValues, FieldPath<FieldValues>>) => {
// Only run once and only for new agents
if (!handled && agent_id === '' && !field.value) {
field.onChange('general');
setHandled(true);
}
},
};
};
/**
* A component for selecting agent categories with form validation
*/
const AgentCategorySelector: React.FC = () => {
const { t } = useTranslation();
const formContext = useFormContext();
const { categories } = useAgentCategories();
// Always call useWatch
const agent_id = useWatch({
name: 'id',
control: formContext.control,
});
// Use custom hook for category sync
const { syncCategory } = useCategorySync(agent_id);
// Transform categories to the format expected by ControlCombobox
const comboboxItems = categories.map((category) => ({
label: category.label,
value: category.value,
}));
const getCategoryDisplayValue = (value: string) => {
const categoryItem = comboboxItems.find((c) => c.value === value);
return categoryItem?.label || comboboxItems.find((c) => c.value === 'general')?.label;
};
const searchPlaceholder = t('com_ui_search_agent_category', 'Search categories...');
const ariaLabel = t('com_ui_agent_category_selector_aria', "Agent's category selector");
return (
<Controller
name="category"
control={formContext.control}
defaultValue="general"
render={({ field }) => {
// Sync category if needed (without using useEffect in render)
syncCategory(field);
const displayValue = getCategoryDisplayValue(field.value);
return (
<ControlCombobox
selectedValue={field.value}
displayValue={displayValue}
searchPlaceholder={searchPlaceholder}
setValue={(value) => {
field.onChange(value);
}}
items={comboboxItems}
className=""
ariaLabel={ariaLabel}
isCollapsed={false}
showCarat={true}
/>
);
}}
/>
);
};
export default AgentCategorySelector;

View file

@ -17,6 +17,7 @@ import FileSearch from './FileSearch';
import Artifacts from './Artifacts';
import AgentTool from './AgentTool';
import CodeForm from './Code/Form';
import AgentCategorySelector from './AgentCategorySelector';
import { Panel } from '~/common';
const labelClass = 'mb-2 text-token-text-primary block font-medium';
@ -238,6 +239,13 @@ export default function AgentConfig({
)}
/>
</div>
{/* Category */}
<div className="mb-4">
<label className={labelClass} htmlFor="category-selector">
{localize('com_ui_category')} <span className="text-red-500">*</span>
</label>
<AgentCategorySelector className="w-full" />
</div>
{/* Instructions */}
<Instructions />
{/* Model and Provider */}
@ -356,6 +364,93 @@ export default function AgentConfig({
</div>
{/* MCP Section */}
{/* <MCPSection /> */}
{/* Support Contact (Optional) */}
<div className="mb-4">
<div className="mb-1.5 flex items-center gap-2">
<span>
<label className="text-token-text-primary block font-medium">
{localize('com_ui_support_contact')}
</label>
</span>
</div>
<div className="space-y-3">
{/* Support Contact Name */}
<div className="flex flex-col">
<label
className="mb-1 flex items-center justify-between"
htmlFor="support-contact-name"
>
<span className="text-sm">{localize('com_ui_support_contact_name')}</span>
</label>
<Controller
name="support_contact.name"
control={control}
rules={{
minLength: {
value: 3,
message: localize('com_ui_support_contact_name_min_length', { minLength: 3 }),
},
}}
render={({ field, fieldState: { error } }) => (
<>
<input
{...field}
value={field.value ?? ''}
className={cn(inputClass, error ? 'border-2 border-red-500' : '')}
id="support-contact-name"
type="text"
placeholder={localize('com_ui_support_contact_name_placeholder')}
aria-label="Support contact name"
/>
{error && (
<span className="text-sm text-red-500 transition duration-300 ease-in-out">
{error.message}
</span>
)}
</>
)}
/>
</div>
{/* Support Contact Email */}
<div className="flex flex-col">
<label
className="mb-1 flex items-center justify-between"
htmlFor="support-contact-email"
>
<span className="text-sm">{localize('com_ui_support_contact_email')}</span>
</label>
<Controller
name="support_contact.email"
control={control}
rules={{
pattern: {
value: /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/,
message: localize('com_ui_support_contact_email_invalid'),
},
}}
render={({ field, fieldState: { error } }) => (
<>
<input
{...field}
value={field.value ?? ''}
className={cn(inputClass, error ? 'border-2 border-red-500' : '')}
id="support-contact-email"
type="email"
placeholder={localize('com_ui_support_contact_email_placeholder')}
aria-label="Support contact email"
/>
{error && (
<span className="text-sm text-red-500 transition duration-300 ease-in-out">
{error.message}
</span>
)}
</>
)}
/>
</div>
</div>
</div>
</div>
<ToolSelectDialog
isOpen={showToolDialog}

View file

@ -0,0 +1,196 @@
import React, { useRef, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import type t from 'librechat-data-provider';
interface SupportContact {
name?: string;
email?: string;
}
interface AgentWithSupport extends t.Agent {
support_contact?: SupportContact;
}
import useLocalize from '~/hooks/useLocalize';
import { useToast } from '~/hooks';
import {
Dialog,
DialogContent,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '~/components/ui';
import { DotsIcon } from '~/components/svg';
import { renderAgentAvatar } from '~/utils/agents';
interface AgentDetailProps {
agent: AgentWithSupport; // The agent data to display
isOpen: boolean; // Whether the detail dialog is open
onClose: () => void; // Callback when dialog is closed
}
/**
* Dialog for displaying agent details
*/
const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) => {
const localize = useLocalize();
const navigate = useNavigate();
const { showToast } = useToast();
const dialogRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [dropdownOpen, setDropdownOpen] = useState(false);
// Close dropdown when clicking outside the dropdown menu
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownOpen &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [dropdownOpen]);
/**
* Navigate to chat with the selected agent
*/
const handleStartChat = () => {
if (agent) {
navigate(`/c/new?agent_id=${agent.id}`);
}
};
/**
* Copy the agent's shareable link to clipboard
*/
const handleCopyLink = () => {
const baseUrl = new URL(window.location.origin);
const chatUrl = `${baseUrl.origin}/c/new?agent_id=${agent.id}`;
navigator.clipboard
.writeText(chatUrl)
.then(() => {
showToast({
message: 'Link copied',
});
})
.catch(() => {
showToast({
message: localize('com_agents_link_copy_failed'),
});
});
};
/**
* Format contact information with mailto links when appropriate
*/
const formatContact = () => {
if (!agent?.support_contact) return null;
const { name, email } = agent.support_contact;
if (name && email) {
return (
<a href={`mailto:${email}`} className="text-primary hover:underline">
{name}
</a>
);
}
if (email) {
return (
<a href={`mailto:${email}`} className="text-primary hover:underline">
{email}
</a>
);
}
if (name) {
return <span>{name}</span>;
}
return null;
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent ref={dialogRef} className="max-h-[90vh] overflow-y-auto py-8 sm:max-w-[450px]">
{/* Context menu - top right */}
<div ref={dropdownRef} className="absolute right-12 top-5 z-50">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-lg text-text-secondary hover:bg-surface-hover hover:text-text-primary dark:hover:bg-surface-hover"
aria-label="More options"
aria-expanded={dropdownOpen}
aria-haspopup="menu"
onClick={(e) => {
e.stopPropagation();
setDropdownOpen(!dropdownOpen);
}}
>
<DotsIcon className="h-4 w-4" />
</Button>
{/* Simple dropdown menu */}
{dropdownOpen && (
<div className="absolute right-0 top-10 z-[9999] w-48 rounded-xl border border-border-light bg-surface-primary py-1 shadow-lg dark:bg-surface-secondary dark:shadow-2xl">
<button
onClick={(e) => {
e.stopPropagation();
setDropdownOpen(false);
handleCopyLink();
}}
className="w-full px-3 py-2 text-left text-sm text-text-primary transition-colors hover:bg-surface-hover focus:bg-surface-hover focus:outline-none"
>
{localize('com_agents_copy_link')}
</button>
</div>
)}
</div>
{/* Agent avatar - top center */}
<div className="mt-6 flex justify-center">{renderAgentAvatar(agent, { size: 'xl' })}</div>
{/* Agent name - center aligned below image */}
<div className="mt-3 text-center">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{agent?.name || localize('com_agents_loading')}
</h2>
</div>
{/* Contact info - center aligned below name */}
{agent?.support_contact && formatContact() && (
<div className="mt-1 text-center text-sm text-gray-600 dark:text-gray-400">
{localize('com_agents_contact')}: {formatContact()}
</div>
)}
{/* Agent description - below contact */}
<div className="mt-4 whitespace-pre-wrap px-6 text-center text-base text-gray-700 dark:text-gray-300">
{agent?.description || (
<span className="italic text-gray-400">{localize('com_agents_no_description')}</span>
)}
</div>
{/* Action button */}
<div className="mb-4 mt-6 flex justify-center">
<Button className="w-full max-w-xs" onClick={handleStartChat} disabled={!agent}>
{localize('com_agents_start_chat')}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default AgentDetail;

View file

@ -0,0 +1,276 @@
import React, { useState } from 'react';
import type t from 'librechat-data-provider';
import { useDynamicAgentQuery, useAgentCategories } from '~/hooks/Agents';
import useLocalize from '~/hooks/useLocalize';
import { Button } from '~/components/ui';
import { Spinner } from '~/components/svg';
import { SmartLoader, 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
}
// Interface for the actual data structure returned by the API
interface AgentGridData {
agents: t.Agent[];
pagination?: {
hasMore: boolean;
current: number;
total: number;
};
}
/**
* Component for displaying a grid of agent cards
*/
const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAgent }) => {
const localize = useLocalize();
const [page, setPage] = useState(1);
// Get category data from API
const { categories } = useAgentCategories();
// Single dynamic query that handles all cases - much cleaner!
const {
data: rawData,
isLoading,
error,
isFetching,
refetch,
} = useDynamicAgentQuery({
category,
searchQuery,
page,
limit: 6,
});
// Type the data properly
const data = rawData as AgentGridData | undefined;
// Check if we have meaningful data to prevent unnecessary loading states
const hasData = useHasData(data);
/**
* Get category display name from API data or use fallback
*/
const getCategoryDisplayName = (categoryValue: string) => {
const categoryData = categories.find((cat) => cat.value === categoryValue);
if (categoryData) {
return categoryData.label;
}
// Fallback for special categories or unknown categories
if (categoryValue === 'promoted') {
return localize('com_agents_top_picks');
}
if (categoryValue === 'all') {
return 'All';
}
// Simple capitalization for unknown categories
return categoryValue.charAt(0).toUpperCase() + categoryValue.slice(1);
};
/**
* Load more agents when "See More" button is clicked
*/
const handleLoadMore = () => {
setPage((prevPage) => prevPage + 1);
};
/**
* Reset page when category or search changes
*/
React.useEffect(() => {
setPage(1);
}, [category, searchQuery]);
/**
* 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-gray-200 dark:bg-gray-700"></div>
<div className="h-4 w-64 animate-pulse rounded-md bg-gray-200 dark:bg-gray-700"></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 h-[250px] animate-pulse flex-col overflow-hidden rounded-lg',
'bg-gray-200 dark:bg-gray-800',
)}
>
<div className="h-40 bg-gray-300 dark:bg-gray-700"></div>
<div className="flex-1 p-5">
<div className="mb-3 h-4 w-3/4 rounded bg-gray-300 dark:bg-gray-700"></div>
<div className="mb-2 h-3 w-full rounded bg-gray-300 dark:bg-gray-700"></div>
<div className="h-3 w-2/3 rounded bg-gray-300 dark:bg-gray-700"></div>
</div>
</div>
))}
</div>
</div>
);
// Handle error state with enhanced error display
if (error) {
return (
<ErrorDisplay
error={error || 'Unknown error occurred'}
onRetry={() => refetch()}
context={{
searchQuery,
category,
}}
/>
);
}
// Main content component with proper semantic structure
const mainContent = (
<div
className="space-y-6"
role="tabpanel"
id={`category-panel-${category}`}
aria-labelledby={`category-tab-${category}`}
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-gray-900 dark:text-white"
id={`category-heading-${category}`}
aria-label={`${getGridTitle()}, ${data?.agents?.length || 0} agents available`}
>
{getGridTitle()}
</h2>
</div>
)}
{/* Handle empty results with enhanced accessibility */}
{(!data?.agents || data.agents.length === 0) && !isLoading && !isFetching ? (
<div
className="py-12 text-center text-gray-500"
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">
{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>
</div>
) : (
<>
{/* Announcement for screen readers */}
<div id="search-results-count" className="sr-only" aria-live="polite" aria-atomic="true">
{localize('com_agents_grid_announcement', {
count: data?.agents?.length || 0,
category: getCategoryDisplayName(category),
})}
</div>
{/* Agent grid - 2 per row with proper semantic structure */}
{data?.agents && data.agents.length > 0 && (
<div
className="grid grid-cols-1 gap-6 md:grid-cols-2"
role="grid"
aria-label={localize('com_agents_grid_announcement', {
count: data.agents.length,
category: getCategoryDisplayName(category),
})}
>
{data.agents.map((agent: t.Agent, index: number) => (
<div key={`${agent.id}-${index}`} role="gridcell">
<AgentCard agent={agent} onClick={() => onSelectAgent(agent)} />
</div>
))}
</div>
)}
{/* Loading indicator when fetching more with accessibility */}
{isFetching && page > 1 && (
<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>
)}
{/* Load more button with enhanced accessibility */}
{data?.pagination?.hasMore && !isFetching && (
<div className="mt-8 flex justify-center">
<Button
variant="outline"
onClick={handleLoadMore}
className={cn(
'min-w-[160px] border-2 border-gray-300 bg-white px-6 py-3 font-medium text-gray-700',
'shadow-sm transition-all duration-200 hover:border-gray-400 hover:bg-gray-50',
'hover:shadow-md focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200',
'dark:hover:border-gray-500 dark:hover:bg-gray-700 dark:focus:ring-blue-400',
)}
aria-label={localize('com_agents_load_more_label', {
category: getCategoryDisplayName(category),
})}
>
{localize('com_agents_see_more')}
</Button>
</div>
)}
</>
)}
</div>
);
// Use SmartLoader to prevent unnecessary loading flashes
return (
<SmartLoader
isLoading={isLoading}
hasData={hasData}
delay={200} // Show loading only after 200ms delay
loadingComponent={loadingSkeleton}
>
{mainContent}
</SmartLoader>
);
};
export default AgentGrid;

View file

@ -0,0 +1,301 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useOutletContext } from 'react-router-dom';
import { useSearchParams, useParams, useNavigate } from 'react-router-dom';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import type t from 'librechat-data-provider';
import type { ContextType } from '~/common';
import { useGetAgentCategoriesQuery, useGetEndpointsQuery } from '~/data-provider';
import { useDocumentTitle } from '~/hooks';
import useLocalize from '~/hooks/useLocalize';
import { TooltipAnchor, Button } from '~/components/ui';
import { NewChatIcon } from '~/components/svg';
import { OpenSidebar } from '~/components/Chat/Menus';
import { SidePanelGroup } from '~/components/SidePanel';
import { MarketplaceProvider } from './MarketplaceContext';
import CategoryTabs from './CategoryTabs';
import AgentDetail from './AgentDetail';
import SearchBar from './SearchBar';
import AgentGrid from './AgentGrid';
import store from '~/store';
interface AgentMarketplaceProps {
className?: string;
}
/**
* AgentMarketplace - Main component for browsing and discovering agents
*
* Provides tabbed navigation for different agent categories,
* search functionality, and detailed agent view through a modal dialog.
* Uses URL parameters for state persistence and deep linking.
*/
const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) => {
const localize = useLocalize();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { category } = useParams();
const setHideSidePanel = useSetRecoilState(store.hideSidePanel);
const hideSidePanel = useRecoilValue(store.hideSidePanel);
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
// Get URL parameters (default to 'promoted' instead of 'all')
const activeTab = category || 'promoted';
const searchQuery = searchParams.get('q') || '';
const selectedAgentId = searchParams.get('agent_id') || '';
// Local state
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [selectedAgent, setSelectedAgent] = useState<t.Agent | null>(null);
// Set page title
useDocumentTitle(`${localize('com_agents_marketplace')} | LibreChat`);
// Ensure right sidebar is always visible in marketplace
useEffect(() => {
setHideSidePanel(false);
// Also try to force expand via localStorage
localStorage.setItem('hideSidePanel', 'false');
localStorage.setItem('fullPanelCollapse', 'false');
}, [setHideSidePanel, hideSidePanel]);
// Ensure endpoints config is loaded first (required for agent queries)
useGetEndpointsQuery();
// Fetch categories using existing query pattern
const categoriesQuery = useGetAgentCategoriesQuery({
staleTime: 1000 * 60 * 15, // 15 minutes - categories rarely change
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
/**
* Handle agent card selection
*
* @param agent - The selected agent object
*/
const handleAgentSelect = (agent: t.Agent) => {
// Update URL with selected agent
const newParams = new URLSearchParams(searchParams);
newParams.set('agent_id', agent.id);
setSearchParams(newParams);
setSelectedAgent(agent);
setIsDetailOpen(true);
};
/**
* Handle closing the agent detail dialog
*/
const handleDetailClose = () => {
const newParams = new URLSearchParams(searchParams);
newParams.delete('agent_id');
setSearchParams(newParams);
setSelectedAgent(null);
setIsDetailOpen(false);
};
/**
* Handle category tab selection changes
*
* @param tabValue - The selected category value
*/
const handleTabChange = (tabValue: string) => {
const currentSearchParams = searchParams.toString();
const searchParamsStr = currentSearchParams ? `?${currentSearchParams}` : '';
// Navigate to the selected category
if (tabValue === 'promoted') {
navigate(`/agents${searchParamsStr}`);
} else {
navigate(`/agents/${tabValue}${searchParamsStr}`);
}
};
/**
* Handle search query changes
*
* @param query - The search query string
*/
const handleSearch = (query: string) => {
const newParams = new URLSearchParams(searchParams);
if (query.trim()) {
newParams.set('q', query.trim());
// Switch to "all" category when starting a new search
navigate(`/agents/all?${newParams.toString()}`);
} else {
newParams.delete('q');
// Preserve current category when clearing search
const currentCategory = activeTab;
if (currentCategory === 'promoted') {
navigate(`/agents${newParams.toString() ? `?${newParams.toString()}` : ''}`);
} else {
navigate(
`/agents/${currentCategory}${newParams.toString() ? `?${newParams.toString()}` : ''}`,
);
}
}
};
/**
* Handle new chat button click
*/
const handleNewChat = (e: React.MouseEvent<HTMLButtonElement>) => {
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
window.open('/c/new', '_blank');
return;
}
navigate('/c/new');
};
// Check if a detail view should be open based on URL
useEffect(() => {
setIsDetailOpen(!!selectedAgentId);
}, [selectedAgentId]);
// Layout configuration for SidePanelGroup
const defaultLayout = useMemo(() => {
const resizableLayout = localStorage.getItem('react-resizable-panels:layout');
return typeof resizableLayout === 'string' ? JSON.parse(resizableLayout) : undefined;
}, []);
const defaultCollapsed = useMemo(() => {
const collapsedPanels = localStorage.getItem('react-resizable-panels:collapsed');
return typeof collapsedPanels === 'string' ? JSON.parse(collapsedPanels) : true;
}, []);
const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []);
return (
<div className={`relative flex w-full grow overflow-hidden bg-presentation ${className}`}>
<MarketplaceProvider>
<SidePanelGroup
defaultLayout={defaultLayout}
fullPanelCollapse={fullCollapse}
defaultCollapsed={defaultCollapsed}
>
<main className="flex h-full flex-col overflow-y-auto" role="main">
{/* Simplified header for agents marketplace - only show nav controls when needed */}
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold text-text-primary dark:bg-gray-800">
<div className="mx-1 flex items-center gap-2">
{!navVisible && <OpenSidebar setNavVisible={setNavVisible} />}
{!navVisible && (
<TooltipAnchor
description={localize('com_ui_new_chat')}
render={
<Button
size="icon"
variant="outline"
data-testid="agents-new-chat-button"
aria-label={localize('com_ui_new_chat')}
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
onClick={handleNewChat}
>
<NewChatIcon />
</Button>
}
/>
)}
</div>
</div>
<div className="container mx-auto max-w-4xl px-4 py-8">
{/* Hero Section - ChatGPT Style */}
<div className="mb-8 mt-12 text-center">
<h1 className="mb-3 text-5xl font-bold tracking-tight text-gray-900 dark:text-white">
{localize('com_agents_marketplace')}
</h1>
<p className="mx-auto mb-6 max-w-2xl text-lg text-gray-600 dark:text-gray-300">
{localize('com_agents_marketplace_subtitle')}
</p>
{/* Search bar */}
<div className="mx-auto max-w-2xl">
<SearchBar value={searchQuery} onSearch={handleSearch} />
</div>
</div>
{/* Category tabs */}
<CategoryTabs
categories={categoriesQuery.data || []}
activeTab={activeTab}
isLoading={categoriesQuery.isLoading}
onChange={handleTabChange}
/>
{/* Category header - only show when not searching */}
{!searchQuery && (
<div className="mb-6">
{(() => {
// Get category data for display
const getCategoryData = () => {
if (activeTab === 'promoted') {
return {
name: localize('com_agents_top_picks'),
description: localize('com_agents_recommended'),
};
}
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 || '',
};
}
// Fallback for unknown categories
return {
name: activeTab.charAt(0).toUpperCase() + activeTab.slice(1),
description: '',
};
};
const { name, description } = getCategoryData();
return (
<div className="text-left">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{name}</h2>
{description && (
<p className="mt-2 text-gray-600 dark:text-gray-300">{description}</p>
)}
</div>
);
})()}
</div>
)}
{/* Agent grid */}
<AgentGrid
category={activeTab}
searchQuery={searchQuery}
onSelectAgent={handleAgentSelect}
/>
</div>
{/* Agent detail dialog */}
{isDetailOpen && selectedAgent && (
<AgentDetail
agent={selectedAgent}
isOpen={isDetailOpen}
onClose={handleDetailClose}
/>
)}
</main>
</SidePanelGroup>
</MarketplaceProvider>
</div>
);
};
export default AgentMarketplace;

View file

@ -183,6 +183,8 @@ export default function AgentPanel({
end_after_tools,
hide_sequential_outputs,
recursion_limit,
category,
support_contact,
} = data;
const model = _model ?? '';
@ -205,6 +207,8 @@ export default function AgentPanel({
end_after_tools,
hide_sequential_outputs,
recursion_limit,
category,
support_contact,
},
});
return;
@ -230,6 +234,8 @@ export default function AgentPanel({
end_after_tools,
hide_sequential_outputs,
recursion_limit,
category,
support_contact,
});
},
[agent_id, create, update, showToast, localize],
@ -263,7 +269,7 @@ export default function AgentPanel({
className="scrollbar-gutter-stable h-auto w-full flex-shrink-0 overflow-x-hidden"
aria-label="Agent configuration form"
>
<div className="mt-2 flex w-full flex-wrap gap-2">
<div className="mx-1 mt-2 flex w-full flex-wrap gap-2">
<div className="w-full">
<AgentSelect
createMutation={create}

View file

@ -77,6 +77,10 @@ export default function AgentSelect({
agent: update,
model: update.model,
tools: agentTools,
// Ensure the category is properly set for the form
category: fullAgent.category || 'general',
// Make sure support_contact is properly loaded
support_contact: fullAgent.support_contact,
};
Object.entries(fullAgent).forEach(([name, value]) => {

View file

@ -0,0 +1,172 @@
import React from 'react';
import type t from 'librechat-data-provider';
import useLocalize from '~/hooks/useLocalize';
import { SmartLoader } from './SmartLoader';
import { cn } from '~/utils';
/**
* Props for the CategoryTabs component
*/
interface CategoryTabsProps {
/** Array of agent categories to display as tabs */
categories: t.TMarketplaceCategory[];
/** Currently selected tab value */
activeTab: string;
/** Whether categories are currently loading */
isLoading: boolean;
/** Callback fired when a tab is selected */
onChange: (value: string) => void;
}
/**
* CategoryTabs - Component for displaying category tabs with counts
*
* 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.
*/
const CategoryTabs: React.FC<CategoryTabsProps> = ({
categories,
activeTab,
isLoading,
onChange,
}) => {
const localize = useLocalize();
// Helper function to get category display name from database data
const getCategoryDisplayName = (category: t.TCategory) => {
// Special cases for system categories
if (category.value === 'promoted') {
return localize('com_agents_top_picks');
}
if (category.value === 'all') {
return 'All';
}
// Use database label or fallback to capitalized value
return category.label || category.value.charAt(0).toUpperCase() + category.value.slice(1);
};
// Loading skeleton component
const loadingSkeleton = (
<div className="mb-8">
<div className="flex justify-center">
<div className="flex flex-wrap items-center justify-center gap-6">
{[...Array(6)].map((_, i) => (
<div
key={i}
className="h-6 min-w-[60px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"
/>
))}
</div>
</div>
</div>
);
// Handle keyboard navigation between tabs
const handleKeyDown = (e: React.KeyboardEvent, currentCategory: string) => {
const currentIndex = categories.findIndex((cat) => cat.value === currentCategory);
let newIndex = currentIndex;
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 'Home':
e.preventDefault();
newIndex = 0;
break;
case 'End':
e.preventDefault();
newIndex = categories.length - 1;
break;
default:
return;
}
const newCategory = categories[newIndex];
if (newCategory) {
onChange(newCategory.value);
// Focus the new tab
setTimeout(() => {
const newTab = document.getElementById(`category-tab-${newCategory.value}`);
newTab?.focus();
}, 0);
}
};
// Early return if no categories available
if (!isLoading && (!categories || categories.length === 0)) {
return (
<div className="mb-8 text-center text-gray-500">{localize('com_agents_no_categories')}</div>
);
}
// Main tabs content
const tabsContent = (
<div className="mb-8">
<div className="flex justify-center">
{/* Accessible tab navigation with proper ARIA attributes */}
<div
className="flex flex-wrap items-center justify-center gap-6"
role="tablist"
aria-label={localize('com_agents_category_tabs_label')}
aria-orientation="horizontal"
>
{categories.map((category, index) => (
<button
key={category.value}
id={`category-tab-${category.value}`}
onClick={() => onChange(category.value)}
onKeyDown={(e) => handleKeyDown(e, category.value)}
className={cn(
'relative px-4 py-2 text-sm font-medium transition-colors duration-200',
'focus:bg-gray-100 focus:outline-none dark:focus:bg-gray-800',
'hover:text-gray-900 dark:hover:text-white',
activeTab === category.value
? 'text-gray-900 dark:text-white'
: 'text-gray-600 dark:text-gray-400',
)}
role="tab"
aria-selected={activeTab === category.value}
aria-controls={`tabpanel-${category.value}`}
tabIndex={activeTab === category.value ? 0 : -1}
aria-label={`${getCategoryDisplayName(category)} tab (${index + 1} of ${categories.length})`}
>
<span className="truncate">{getCategoryDisplayName(category)}</span>
{/* Underline for active tab */}
{activeTab === category.value && (
<div
className="absolute bottom-0 left-0 right-0 h-0.5 rounded-full bg-gray-900 dark:bg-white"
aria-hidden="true"
/>
)}
</button>
))}
</div>
</div>
</div>
);
// Use SmartLoader to prevent category loading flashes
return (
<SmartLoader
isLoading={isLoading}
hasData={categories?.length > 0}
delay={100} // Very short delay since categories should load quickly
loadingComponent={loadingSkeleton}
>
{tabsContent}
</SmartLoader>
);
};
export default CategoryTabs;

View file

@ -0,0 +1,239 @@
import React from 'react';
import { useLocalize } from '~/hooks';
import { Button } from '~/components/ui';
import { cn } from '~/utils';
// Comprehensive error type that handles all possible error structures
type ApiError =
| string
| Error
| {
message?: string;
status?: number;
code?: string;
response?: {
data?: {
userMessage?: string;
suggestion?: string;
message?: string;
};
status?: number;
};
data?: {
userMessage?: string;
suggestion?: string;
message?: string;
};
};
interface ErrorDisplayProps {
error: ApiError;
onRetry?: () => void;
context?: {
searchQuery?: string;
category?: string;
};
}
/**
* User-friendly error display component with actionable suggestions
*/
export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ error, onRetry, context }) => {
const localize = useLocalize();
// Type guards
const isErrorObject = (err: ApiError): err is { [key: string]: unknown } => {
return typeof err === 'object' && err !== null && !(err instanceof Error);
};
const isErrorInstance = (err: ApiError): err is Error => {
return err instanceof Error;
};
// Extract user-friendly error information
const getErrorInfo = (): { title: string; message: string; suggestion: string } => {
// Handle different error types
let errorData: unknown;
if (typeof error === 'string') {
errorData = { message: error };
} else if (isErrorInstance(error)) {
errorData = { message: error.message };
} else if (isErrorObject(error)) {
// Handle axios error response structure
errorData = (error as any)?.response?.data || (error as any)?.data || error;
} else {
errorData = error;
}
// Use user-friendly message from backend if available
if (errorData && typeof errorData === 'object' && (errorData as any)?.userMessage) {
return {
title: getContextualTitle(),
message: (errorData as any).userMessage,
suggestion:
(errorData as any).suggestion || localize('com_agents_error_suggestion_generic'),
};
}
// Handle network errors
const errorMessage = isErrorInstance(error)
? error.message
: isErrorObject(error) && (error as any)?.message
? (error as any).message
: '';
const errorCode = isErrorObject(error) ? (error as any)?.code : '';
if (errorCode === 'NETWORK_ERROR' || errorMessage?.includes('Network Error')) {
return {
title: localize('com_agents_error_network_title'),
message: localize('com_agents_error_network_message'),
suggestion: localize('com_agents_error_network_suggestion'),
};
}
// Handle specific HTTP status codes
const status = isErrorObject(error) ? (error as any)?.response?.status : null;
if (status) {
if (status === 404) {
return {
title: localize('com_agents_error_not_found_title'),
message: getNotFoundMessage(),
suggestion: localize('com_agents_error_not_found_suggestion'),
};
}
if (status === 400) {
return {
title: localize('com_agents_error_invalid_request'),
message:
(errorData as any)?.userMessage || localize('com_agents_error_bad_request_message'),
suggestion: localize('com_agents_error_bad_request_suggestion'),
};
}
if (status >= 500) {
return {
title: localize('com_agents_error_server_title'),
message: localize('com_agents_error_server_message'),
suggestion: localize('com_agents_error_server_suggestion'),
};
}
}
// Fallback to generic error
return {
title: localize('com_agents_error_title'),
message: localize('com_agents_error_generic'),
suggestion: localize('com_agents_error_suggestion_generic'),
};
};
/**
* Get contextual title based on current operation
*/
const getContextualTitle = (): string => {
if (context?.searchQuery) {
return localize('com_agents_error_search_title');
}
if (context?.category) {
return localize('com_agents_error_category_title');
}
return localize('com_agents_error_title');
};
/**
* Get context-specific not found message
*/
const getNotFoundMessage = (): string => {
if (context?.searchQuery) {
return localize('com_agents_search_no_results', { query: context.searchQuery });
}
if (context?.category && context.category !== 'all') {
return localize('com_agents_category_empty', { category: context.category });
}
return localize('com_agents_error_not_found_message');
};
const { title, message, suggestion } = getErrorInfo();
return (
<div className="py-12 text-center" role="alert" aria-live="assertive" aria-atomic="true">
<div className="mx-auto max-w-md space-y-4">
{/* Error icon with proper accessibility */}
<div className="flex justify-center">
<div
className={cn(
'flex h-12 w-12 items-center justify-center rounded-full',
'bg-red-100 dark:bg-red-900/20',
)}
>
<svg
className="h-6 w-6 text-red-600 dark:text-red-400"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
aria-hidden="true"
role="img"
aria-label="Error icon"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
</div>
</div>
{/* Error content with proper headings and structure */}
<div className="space-y-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white" id="error-title">
{title}
</h2>
<p
className="text-gray-600 dark:text-gray-400"
id="error-message"
aria-describedby="error-title"
>
{message}
</p>
<p
className="text-sm text-gray-500 dark:text-gray-500"
id="error-suggestion"
role="note"
aria-label={`Suggestion: ${suggestion}`}
>
💡 {suggestion}
</p>
</div>
{/* Retry button with enhanced accessibility */}
{onRetry && (
<div className="pt-2">
<Button
onClick={onRetry}
variant="outline"
size="sm"
className={cn(
'border-red-300 text-red-700 hover:bg-red-50 focus:ring-2 focus:ring-red-500',
'dark:border-red-600 dark:text-red-400 dark:hover:bg-red-900/20 dark:focus:ring-red-400',
)}
aria-describedby="error-message error-suggestion"
aria-label={`Retry action. ${message}`}
>
{localize('com_agents_error_retry')}
</Button>
</div>
)}
</div>
</div>
);
};
export default ErrorDisplay;

View file

@ -19,7 +19,7 @@ export default function ImageVision() {
{...field}
checked={field.value}
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value?.toString()}
/>
)}

View file

@ -0,0 +1,44 @@
import React, { useMemo } from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import { ChatContext } from '~/Providers';
/**
* Minimal marketplace provider that provides only what SidePanel actually needs
* Replaces the bloated 44-function ChatContext implementation
*/
interface MarketplaceProviderProps {
children: React.ReactNode;
}
export const MarketplaceProvider: React.FC<MarketplaceProviderProps> = ({ children }) => {
// Create more complete context to prevent FileRow and other component errors
// when agents with files are opened in the marketplace
const marketplaceContext = useMemo(
() => ({
conversation: {
endpoint: EModelEndpoint.agents,
conversationId: 'marketplace',
title: 'Agent Marketplace',
},
// File-related context properties to prevent FileRow errors
files: new Map(),
setFiles: () => {},
setFilesLoading: () => {},
// Other commonly used context properties to prevent undefined errors
isSubmitting: false,
setIsSubmitting: () => {},
latestMessage: null,
setLatestMessage: () => {},
// Minimal functions to prevent errors when components try to use them
ask: () => {},
regenerate: () => {},
stopGenerating: () => {},
submitMessage: () => {},
}),
[],
);
return <ChatContext.Provider value={marketplaceContext as any}>{children}</ChatContext.Provider>;
};

View file

@ -0,0 +1,111 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Search, X } from 'lucide-react';
import useLocalize from '~/hooks/useLocalize';
import { useDebounce } from '~/hooks';
import { Input } from '~/components/ui';
/**
* Props for the SearchBar component
*/
interface SearchBarProps {
/** Current search query value */
value: string;
/** Callback fired when the search query changes */
onSearch: (query: string) => void;
/** Additional CSS classes */
className?: string;
}
/**
* SearchBar - Component for searching agents with debounced input
*
* Provides a search input with clear button and debounced search functionality.
* Includes proper ARIA attributes for accessibility and visual indicators.
* Uses 300ms debounce delay to prevent excessive API calls during typing.
*/
const SearchBar: React.FC<SearchBarProps> = ({ value, onSearch, className = '' }) => {
const localize = useLocalize();
const [searchTerm, setSearchTerm] = useState(value);
// Debounced search value (300ms delay)
const debouncedSearchTerm = useDebounce(searchTerm, 300);
// Update internal state when props change
useEffect(() => {
setSearchTerm(value);
}, [value]);
// Trigger search when debounced value changes
useEffect(() => {
// Only trigger search if the debounced value matches current searchTerm
// This prevents stale debounced values from triggering after clear
if (debouncedSearchTerm !== value && debouncedSearchTerm === searchTerm) {
onSearch(debouncedSearchTerm);
}
}, [debouncedSearchTerm, onSearch, value, searchTerm]);
/**
* Handle search input changes
*
* @param e - Input change event
*/
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value);
};
/**
* Clear the search input and reset results
*/
const handleClear = useCallback(() => {
// Immediately call parent onSearch to clear the URL parameter
onSearch('');
// Also clear local state
setSearchTerm('');
}, [onSearch]);
return (
<div className={`relative w-full max-w-4xl ${className}`} role="search">
<label htmlFor="agent-search" className="sr-only">
{localize('com_agents_search_instructions')}
</label>
<Input
id="agent-search"
type="text"
value={searchTerm}
onChange={handleChange}
placeholder={localize('com_agents_search_placeholder')}
className="h-14 rounded-2xl border-2 border-gray-200 bg-white pl-12 pr-12 text-lg text-gray-900 shadow-lg placeholder:text-gray-500 focus:border-gray-300 focus:ring-0 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-400 dark:focus:border-gray-500"
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-gray-400" />
</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-gray-400 transition-colors duration-150 hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-gray-500 dark:hover:bg-gray-400"
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} />
</button>
)}
</div>
);
};
export default SearchBar;

View file

@ -135,10 +135,9 @@ export default function ShareAgent({
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
removeFocusOutlines,
)}
aria-label={localize(
'com_ui_share_var',
{ 0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent') },
)}
aria-label={localize('com_ui_share_var', {
0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'),
})}
type="button"
>
<div className="flex items-center justify-center gap-2 text-blue-500">
@ -148,10 +147,9 @@ export default function ShareAgent({
</OGDialogTrigger>
<OGDialogContent className="w-11/12 md:max-w-xl">
<OGDialogTitle>
{localize(
'com_ui_share_var',
{ 0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent') },
)}
{localize('com_ui_share_var', {
0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'),
})}
</OGDialogTitle>
<form
className="p-2"

View file

@ -0,0 +1,97 @@
import React, { useState, useEffect } from 'react';
import { cn } from '~/utils';
interface SmartLoaderProps {
/** Whether the content is currently loading */
isLoading: boolean;
/** Whether there is existing data to show */
hasData: boolean;
/** Delay before showing loading state (in ms) - prevents flashing for quick loads */
delay?: number;
/** Loading skeleton/spinner component */
loadingComponent: React.ReactNode;
/** Content to show when loaded */
children: React.ReactNode;
/** Additional CSS classes */
className?: string;
}
/**
* SmartLoader - Intelligent loading wrapper that prevents flashing
*
* Only shows loading states when:
* 1. Actually loading AND no existing data
* 2. Loading has lasted longer than the delay threshold
*
* This prevents brief loading flashes for cached/fast responses
*/
export const SmartLoader: React.FC<SmartLoaderProps> = ({
isLoading,
hasData,
delay = 150,
loadingComponent,
children,
className = '',
}) => {
const [shouldShowLoading, setShouldShowLoading] = useState(false);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (isLoading && !hasData) {
// Only show loading after delay to prevent flashing
timeoutId = setTimeout(() => {
setShouldShowLoading(true);
}, delay);
} else {
// Immediately hide loading when done
setShouldShowLoading(false);
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [isLoading, hasData, delay]);
// Show loading state only if we've determined it should be shown
if (shouldShowLoading) {
return <div className={className}>{loadingComponent}</div>;
}
// Show content (including when loading but we have existing data)
return <div className={className}>{children}</div>;
};
/**
* Hook to determine if we have meaningful data to show
* Helps prevent loading states when we already have cached content
*/
export const useHasData = (data: unknown): boolean => {
if (!data) return false;
// Type guard for object data
if (typeof data === 'object' && data !== null) {
// Check for agent list data
if ('agents' in data) {
const agents = (data as any).agents;
return Array.isArray(agents) && agents.length > 0;
}
// Check for single agent data
if ('id' in data || 'name' in data) {
return true;
}
}
// Check for categories data (array)
if (Array.isArray(data)) {
return data.length > 0;
}
return false;
};
export default SmartLoader;

View file

@ -0,0 +1,422 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import CategoryTabs from '../CategoryTabs';
import AgentGrid from '../AgentGrid';
import AgentCard from '../AgentCard';
import SearchBar from '../SearchBar';
import ErrorDisplay from '../ErrorDisplay';
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(() => ({
matches: false,
media: '',
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Mock hooks
jest.mock(
'~/hooks/useLocalize',
() => () =>
jest.fn((key: string, options?: any) => {
const translations: Record<string, string> = {
com_agents_category_tabs_label: 'Agent Categories',
com_agents_category_tab_label: `${options?.category} category, ${options?.position} of ${options?.total}`,
com_agents_search_instructions: 'Type to search agents by name or description',
com_agents_search_aria: 'Search agents',
com_agents_search_placeholder: 'Search agents...',
com_agents_clear_search: 'Clear search',
com_agents_agent_card_label: `${options?.name} agent. ${options?.description}`,
com_agents_no_description: 'No description available',
com_agents_grid_announcement: `Showing ${options?.count} agents in ${options?.category} category`,
com_agents_load_more_label: `Load more agents from ${options?.category} category`,
com_agents_error_retry: 'Try Again',
com_agents_loading: 'Loading...',
com_agents_empty_state_heading: 'No agents found',
com_agents_search_empty_heading: 'No search results',
};
return translations[key] || key;
}),
);
jest.mock('~/hooks/Agents', () => ({
useDynamicAgentQuery: jest.fn(),
}));
const { useDynamicAgentQuery } = require('~/hooks/Agents');
// Create wrapper with QueryClient
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('Accessibility Improvements', () => {
beforeEach(() => {
useDynamicAgentQuery.mockClear();
});
describe('CategoryTabs Accessibility', () => {
const categories = [
{ name: 'promoted', count: 5 },
{ name: 'all', count: 20 },
{ name: 'productivity', count: 8 },
];
it('implements proper tablist role and ARIA attributes', () => {
render(
<CategoryTabs
categories={categories}
activeTab="promoted"
isLoading={false}
onChange={jest.fn()}
/>,
);
// Check tablist role
const tablist = screen.getByRole('tablist');
expect(tablist).toBeInTheDocument();
expect(tablist).toHaveAttribute('aria-label', 'Agent Categories');
expect(tablist).toHaveAttribute('aria-orientation', 'horizontal');
// Check individual tabs
const tabs = screen.getAllByRole('tab');
expect(tabs).toHaveLength(3);
tabs.forEach((tab, index) => {
expect(tab).toHaveAttribute('aria-selected');
expect(tab).toHaveAttribute('aria-controls');
expect(tab).toHaveAttribute('id');
});
});
it('supports keyboard navigation', () => {
const onChange = jest.fn();
render(
<CategoryTabs
categories={categories}
activeTab="promoted"
isLoading={false}
onChange={onChange}
/>,
);
const promotedTab = screen.getByRole('tab', { name: /promoted category/ });
// Test arrow key navigation
fireEvent.keyDown(promotedTab, { key: 'ArrowRight' });
expect(onChange).toHaveBeenCalledWith('all');
fireEvent.keyDown(promotedTab, { key: 'ArrowLeft' });
expect(onChange).toHaveBeenCalledWith('productivity');
fireEvent.keyDown(promotedTab, { key: 'Home' });
expect(onChange).toHaveBeenCalledWith('promoted');
fireEvent.keyDown(promotedTab, { key: 'End' });
expect(onChange).toHaveBeenCalledWith('productivity');
});
it('manages focus correctly', () => {
const onChange = jest.fn();
render(
<CategoryTabs
categories={categories}
activeTab="promoted"
isLoading={false}
onChange={onChange}
/>,
);
const promotedTab = screen.getByRole('tab', { name: /promoted category/ });
const allTab = screen.getByRole('tab', { name: /all category/ });
// Active tab should be focusable
expect(promotedTab).toHaveAttribute('tabIndex', '0');
expect(allTab).toHaveAttribute('tabIndex', '-1');
});
});
describe('SearchBar Accessibility', () => {
it('provides proper search role and labels', () => {
render(<SearchBar value="" onSearch={jest.fn()} />);
// Check search landmark
const searchRegion = screen.getByRole('search');
expect(searchRegion).toBeInTheDocument();
// Check input accessibility
const searchInput = screen.getByRole('searchbox');
expect(searchInput).toHaveAttribute('id', 'agent-search');
expect(searchInput).toHaveAttribute('aria-label', 'Search agents');
expect(searchInput).toHaveAttribute(
'aria-describedby',
'search-instructions search-results-count',
);
// Check hidden label
expect(screen.getByText('Type to search agents by name or description')).toHaveClass(
'sr-only',
);
});
it('provides accessible clear button', () => {
render(<SearchBar value="test" onSearch={jest.fn()} />);
const clearButton = screen.getByRole('button', { name: 'Clear search' });
expect(clearButton).toBeInTheDocument();
expect(clearButton).toHaveAttribute('aria-label', 'Clear search');
expect(clearButton).toHaveAttribute('title', 'Clear search');
});
it('hides decorative icons from screen readers', () => {
render(<SearchBar value="" onSearch={jest.fn()} />);
// Search icon should be hidden
const iconContainer = document.querySelector('[aria-hidden="true"]');
expect(iconContainer).toBeInTheDocument();
});
});
describe('AgentCard Accessibility', () => {
const mockAgent = {
id: 'test-agent',
name: 'Test Agent',
description: 'A test agent for testing',
authorName: 'Test Author',
};
it('provides comprehensive ARIA labels', () => {
render(<AgentCard agent={mockAgent} onClick={jest.fn()} />);
const card = screen.getByRole('button');
expect(card).toHaveAttribute('aria-label', 'Test Agent agent. A test agent for testing');
expect(card).toHaveAttribute('aria-describedby', 'agent-test-agent-description');
expect(card).toHaveAttribute('role', 'button');
});
it('handles agents without descriptions', () => {
const agentWithoutDesc = { ...mockAgent, description: undefined };
render(<AgentCard agent={agentWithoutDesc} onClick={jest.fn()} />);
expect(screen.getByText('No description available')).toBeInTheDocument();
});
it('supports keyboard interaction', () => {
const onClick = jest.fn();
render(<AgentCard agent={mockAgent} onClick={onClick} />);
const card = screen.getByRole('button');
fireEvent.keyDown(card, { key: 'Enter' });
expect(onClick).toHaveBeenCalledTimes(1);
fireEvent.keyDown(card, { key: ' ' });
expect(onClick).toHaveBeenCalledTimes(2);
});
});
describe('AgentGrid Accessibility', () => {
beforeEach(() => {
useDynamicAgentQuery.mockReturnValue({
data: {
agents: [
{ id: '1', name: 'Agent 1', description: 'First agent' },
{ id: '2', name: 'Agent 2', description: 'Second agent' },
],
pagination: { hasMore: false, total: 2, current: 1 },
},
isLoading: false,
isFetching: false,
error: null,
refetch: jest.fn(),
});
});
it('implements proper tabpanel structure', () => {
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
</Wrapper>,
);
// Check tabpanel role
const tabpanel = screen.getByRole('tabpanel');
expect(tabpanel).toHaveAttribute('id', 'category-panel-all');
expect(tabpanel).toHaveAttribute('aria-labelledby', 'category-tab-all');
expect(tabpanel).toHaveAttribute('aria-live', 'polite');
});
it('provides grid structure with proper roles', () => {
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
</Wrapper>,
);
// Check grid role
const grid = screen.getByRole('grid');
expect(grid).toBeInTheDocument();
expect(grid).toHaveAttribute('aria-label', 'Showing 2 agents in all category');
// Check gridcells
const gridcells = screen.getAllByRole('gridcell');
expect(gridcells).toHaveLength(2);
});
it('announces loading states to screen readers', () => {
useDynamicAgentQuery.mockReturnValue({
data: { agents: [{ id: '1', name: 'Agent 1' }] },
isLoading: false,
isFetching: true,
error: null,
refetch: jest.fn(),
});
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
</Wrapper>,
);
// Check for loading announcement
const loadingStatus = screen.getByRole('status', { name: 'Loading...' });
expect(loadingStatus).toBeInTheDocument();
expect(loadingStatus).toHaveAttribute('aria-live', 'polite');
});
it('provides accessible empty states', () => {
useDynamicAgentQuery.mockReturnValue({
data: { agents: [], pagination: { hasMore: false, total: 0, current: 1 } },
isLoading: false,
isFetching: false,
error: null,
refetch: jest.fn(),
});
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
</Wrapper>,
);
// Check empty state accessibility
const emptyState = screen.getByRole('status');
expect(emptyState).toHaveAttribute('aria-live', 'polite');
expect(emptyState).toHaveAttribute('aria-label', 'No agents found');
});
});
describe('ErrorDisplay Accessibility', () => {
const mockError = {
response: {
data: {
userMessage: 'Unable to load agents. Please try refreshing the page.',
suggestion: 'Try refreshing the page or check your network connection',
},
},
};
it('implements proper alert role and ARIA attributes', () => {
render(<ErrorDisplay error={mockError} onRetry={jest.fn()} />);
// Check alert role
const alert = screen.getByRole('alert');
expect(alert).toHaveAttribute('aria-live', 'assertive');
expect(alert).toHaveAttribute('aria-atomic', 'true');
// Check heading structure
const heading = screen.getByRole('heading', { level: 2 });
expect(heading).toHaveAttribute('id', 'error-title');
});
it('provides accessible retry button', () => {
const onRetry = jest.fn();
render(<ErrorDisplay error={mockError} onRetry={onRetry} />);
const retryButton = screen.getByRole('button', { name: /retry action/i });
expect(retryButton).toHaveAttribute('aria-describedby', 'error-message error-suggestion');
fireEvent.click(retryButton);
expect(onRetry).toHaveBeenCalledTimes(1);
});
it('structures error content with proper semantics', () => {
render(<ErrorDisplay error={mockError} />);
// Check error message structure
expect(screen.getByText(/unable to load agents/i)).toHaveAttribute('id', 'error-message');
// Check suggestion note
const suggestion = screen.getByRole('note');
expect(suggestion).toHaveAttribute('aria-label', expect.stringContaining('Suggestion:'));
});
});
describe('Focus Management', () => {
it('maintains proper focus ring styles', () => {
const { container } = render(<SearchBar value="" onSearch={jest.fn()} />);
// Check for focus styles in CSS classes
const searchInput = container.querySelector('input');
expect(searchInput?.className).toContain('focus:');
});
it('provides visible focus indicators on interactive elements', () => {
render(
<CategoryTabs
categories={[{ name: 'test', count: 1 }]}
activeTab="test"
isLoading={false}
onChange={jest.fn()}
/>,
);
const tab = screen.getByRole('tab');
expect(tab.className).toContain('focus:outline-none');
expect(tab.className).toContain('focus:ring-2');
});
});
describe('Screen Reader Announcements', () => {
it('includes live regions for dynamic content', () => {
const Wrapper = createWrapper();
render(
<Wrapper>
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
</Wrapper>,
);
// Check for live region
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).toBeInTheDocument();
});
it('provides screen reader only content', () => {
render(<SearchBar value="" onSearch={jest.fn()} />);
// Check for screen reader only instructions
const srOnlyElement = document.querySelector('.sr-only');
expect(srOnlyElement).toBeInTheDocument();
});
});
});
export default {};

View file

@ -0,0 +1,197 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import AgentCard from '../AgentCard';
import type t from 'librechat-data-provider';
// Mock useLocalize hook
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
const mockTranslations: Record<string, string> = {
com_agents_created_by: 'Created by',
};
return mockTranslations[key] || key;
});
describe('AgentCard', () => {
const mockAgent: t.Agent = {
id: '1',
name: 'Test Agent',
description: 'A test agent for testing purposes',
support_contact: {
name: 'Test Support',
email: 'test@example.com',
},
avatar: '/test-avatar.png',
} as t.Agent;
const mockOnClick = jest.fn();
beforeEach(() => {
mockOnClick.mockClear();
});
it('renders agent information correctly', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
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('Test Support')).toBeInTheDocument();
});
it('displays avatar when provided as string', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
const avatarImg = screen.getByAltText('Test Agent avatar');
expect(avatarImg).toBeInTheDocument();
expect(avatarImg).toHaveAttribute('src', '/test-avatar.png');
});
it('displays avatar when provided as object with filepath', () => {
const agentWithObjectAvatar = {
...mockAgent,
avatar: { filepath: '/object-avatar.png' },
};
render(<AgentCard agent={agentWithObjectAvatar} onClick={mockOnClick} />);
const avatarImg = screen.getByAltText('Test Agent avatar');
expect(avatarImg).toBeInTheDocument();
expect(avatarImg).toHaveAttribute('src', '/object-avatar.png');
});
it('displays Bot icon fallback when no avatar is provided', () => {
const agentWithoutAvatar = {
...mockAgent,
avatar: undefined,
};
render(<AgentCard agent={agentWithoutAvatar} onClick={mockOnClick} />);
// Check for Bot icon presence by looking for the svg with lucide-bot class
const botIcon = document.querySelector('.lucide-bot');
expect(botIcon).toBeInTheDocument();
});
it('calls onClick when card is clicked', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
const card = screen.getByRole('button');
fireEvent.click(card);
expect(mockOnClick).toHaveBeenCalledTimes(1);
});
it('calls onClick when Enter key is pressed', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
const card = screen.getByRole('button');
fireEvent.keyDown(card, { key: 'Enter' });
expect(mockOnClick).toHaveBeenCalledTimes(1);
});
it('calls onClick when Space key is pressed', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
const card = screen.getByRole('button');
fireEvent.keyDown(card, { key: ' ' });
expect(mockOnClick).toHaveBeenCalledTimes(1);
});
it('does not call onClick for other keys', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
const card = screen.getByRole('button');
fireEvent.keyDown(card, { key: 'Escape' });
expect(mockOnClick).not.toHaveBeenCalled();
});
it('applies additional className when provided', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} className="custom-class" />);
const card = screen.getByRole('button');
expect(card).toHaveClass('custom-class');
});
it('handles missing support contact gracefully', () => {
const agentWithoutContact = {
...mockAgent,
support_contact: undefined,
authorName: undefined,
};
render(<AgentCard agent={agentWithoutContact} onClick={mockOnClick} />);
expect(screen.getByText('Test Agent')).toBeInTheDocument();
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
expect(screen.queryByText(/Created by/)).not.toBeInTheDocument();
});
it('displays authorName when support_contact is missing', () => {
const agentWithAuthorName = {
...mockAgent,
support_contact: undefined,
authorName: 'John Doe',
};
render(<AgentCard agent={agentWithAuthorName} onClick={mockOnClick} />);
expect(screen.getByText('Created by')).toBeInTheDocument();
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
it('displays support_contact email when name is missing', () => {
const agentWithEmailOnly = {
...mockAgent,
support_contact: { email: 'contact@example.com' },
authorName: undefined,
};
render(<AgentCard agent={agentWithEmailOnly} onClick={mockOnClick} />);
expect(screen.getByText('Created by')).toBeInTheDocument();
expect(screen.getByText('contact@example.com')).toBeInTheDocument();
});
it('prioritizes support_contact name over authorName', () => {
const agentWithBoth = {
...mockAgent,
support_contact: { name: 'Support Team' },
authorName: 'John Doe',
};
render(<AgentCard agent={agentWithBoth} onClick={mockOnClick} />);
expect(screen.getByText('Created by')).toBeInTheDocument();
expect(screen.getByText('Support Team')).toBeInTheDocument();
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
});
it('prioritizes name over email in support_contact', () => {
const agentWithNameAndEmail = {
...mockAgent,
support_contact: {
name: 'Support Team',
email: 'support@example.com',
},
authorName: undefined,
};
render(<AgentCard agent={agentWithNameAndEmail} onClick={mockOnClick} />);
expect(screen.getByText('Created by')).toBeInTheDocument();
expect(screen.getByText('Support Team')).toBeInTheDocument();
expect(screen.queryByText('support@example.com')).not.toBeInTheDocument();
});
it('has proper accessibility attributes', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
const card = screen.getByRole('button');
expect(card).toHaveAttribute('tabIndex', '0');
expect(card).toHaveAttribute('aria-label', 'com_agents_agent_card_label');
});
});

View file

@ -0,0 +1,90 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import AgentCategoryDisplay from '../AgentCategoryDisplay';
// Mock the useAgentCategories hook
jest.mock('~/hooks/Agents', () => ({
useAgentCategories: () => ({
categories: [
{
value: 'general',
label: 'General',
icon: <span data-testid="icon-general">{''}</span>,
className: 'w-full',
},
{
value: 'hr',
label: 'HR',
icon: <span data-testid="icon-hr">{''}</span>,
className: 'w-full',
},
{
value: 'rd',
label: 'R&D',
icon: <span data-testid="icon-rd">{''}</span>,
className: 'w-full',
},
{
value: 'finance',
label: 'Finance',
icon: <span data-testid="icon-finance">{''}</span>,
className: 'w-full',
},
],
emptyCategory: {
value: '',
label: 'General',
className: 'w-full',
},
}),
}));
describe('AgentCategoryDisplay', () => {
it('should display the proper label for a category', () => {
render(<AgentCategoryDisplay category="rd" />);
expect(screen.getByText('R&D')).toBeInTheDocument();
});
it('should display the icon when showIcon is true', () => {
render(<AgentCategoryDisplay category="finance" showIcon={true} />);
expect(screen.getByTestId('icon-finance')).toBeInTheDocument();
expect(screen.getByText('Finance')).toBeInTheDocument();
});
it('should not display the icon when showIcon is false', () => {
render(<AgentCategoryDisplay category="hr" showIcon={false} />);
expect(screen.queryByTestId('icon-hr')).not.toBeInTheDocument();
expect(screen.getByText('HR')).toBeInTheDocument();
});
it('should apply custom classnames', () => {
render(<AgentCategoryDisplay category="general" className="test-class" />);
expect(screen.getByText('General').parentElement).toHaveClass('test-class');
});
it('should not render anything for unknown categories', () => {
const { container } = render(<AgentCategoryDisplay category="unknown" />);
expect(container).toBeEmptyDOMElement();
});
it('should not render anything when no category is provided', () => {
const { container } = render(<AgentCategoryDisplay />);
expect(container).toBeEmptyDOMElement();
});
it('should not render anything for empty category when showEmptyFallback is false', () => {
const { container } = render(<AgentCategoryDisplay category="" />);
expect(container).toBeEmptyDOMElement();
});
it('should render empty category placeholder when showEmptyFallback is true', () => {
render(<AgentCategoryDisplay category="" showEmptyFallback={true} />);
expect(screen.getByText('General')).toBeInTheDocument();
});
it('should apply custom iconClassName to the icon', () => {
render(<AgentCategoryDisplay category="general" iconClassName="custom-icon-class" />);
const iconElement = screen.getByTestId('icon-general').parentElement;
expect(iconElement).toHaveClass('custom-icon-class');
});
});

View file

@ -0,0 +1,359 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, useNavigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RecoilRoot } from 'recoil';
import type t from 'librechat-data-provider';
import AgentDetail from '../AgentDetail';
import { useToast } from '~/hooks';
import useLocalize from '~/hooks/useLocalize';
// Mock dependencies
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
}));
jest.mock('~/hooks', () => ({
useToast: jest.fn(),
}));
jest.mock('~/hooks/useLocalize', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('~/utils/agents', () => ({
renderAgentAvatar: jest.fn((agent, options) => (
<div data-testid="agent-avatar" data-size={options?.size} />
)),
}));
// Mock clipboard API
Object.assign(navigator, {
clipboard: {
writeText: jest.fn(),
},
});
const mockNavigate = jest.fn();
const mockShowToast = jest.fn();
const mockLocalize = jest.fn((key: string) => key);
const mockAgent: t.Agent = {
id: 'test-agent-id',
name: 'Test Agent',
description: 'This is a test agent for unit testing',
avatar: {
filepath: '/path/to/avatar.png',
source: 'local' as const,
},
model: 'gpt-4',
provider: 'openai',
instructions: 'You are a helpful test agent',
tools: [],
code_interpreter: false,
file_search: false,
author: 'test-user-id',
author_name: 'Test User',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
version: 1,
support_contact: {
name: 'Support Team',
email: 'support@test.com',
},
};
// Helper function to render with providers
const renderWithProviders = (ui: React.ReactElement, options = {}) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<MemoryRouter>{children}</MemoryRouter>
</RecoilRoot>
</QueryClientProvider>
);
return render(ui, { wrapper: Wrapper, ...options });
};
describe('AgentDetail', () => {
beforeEach(() => {
jest.clearAllMocks();
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);
(useToast as jest.Mock).mockReturnValue({ showToast: mockShowToast });
(useLocalize as jest.Mock).mockReturnValue(mockLocalize);
// Reset clipboard mock
(navigator.clipboard.writeText as jest.Mock).mockResolvedValue(undefined);
});
const defaultProps = {
agent: mockAgent,
isOpen: true,
onClose: jest.fn(),
};
describe('Rendering', () => {
it('should render agent details correctly', () => {
renderWithProviders(<AgentDetail {...defaultProps} />);
expect(screen.getByText('Test Agent')).toBeInTheDocument();
expect(screen.getByText('This is a test agent for unit testing')).toBeInTheDocument();
expect(screen.getByTestId('agent-avatar')).toBeInTheDocument();
expect(screen.getByTestId('agent-avatar')).toHaveAttribute('data-size', 'xl');
});
it('should render contact information when available', () => {
renderWithProviders(<AgentDetail {...defaultProps} />);
expect(screen.getByText('com_agents_contact:')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Support Team' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Support Team' })).toHaveAttribute(
'href',
'mailto:support@test.com',
);
});
it('should not render contact information when not available', () => {
const agentWithoutContact = { ...mockAgent };
delete (agentWithoutContact as any).support_contact;
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithoutContact} />);
expect(screen.queryByText('com_agents_contact:')).not.toBeInTheDocument();
});
it('should render loading state when agent is null', () => {
renderWithProviders(<AgentDetail {...defaultProps} agent={null as any} />);
expect(screen.getByText('com_agents_loading')).toBeInTheDocument();
expect(screen.getByText('com_agents_no_description')).toBeInTheDocument();
});
it('should render 3-dot menu button', () => {
renderWithProviders(<AgentDetail {...defaultProps} />);
const menuButton = screen.getByRole('button', { name: 'More options' });
expect(menuButton).toBeInTheDocument();
expect(menuButton).toHaveAttribute('aria-haspopup', 'menu');
});
it('should render Start Chat button', () => {
renderWithProviders(<AgentDetail {...defaultProps} />);
const startChatButton = screen.getByRole('button', { name: 'com_agents_start_chat' });
expect(startChatButton).toBeInTheDocument();
expect(startChatButton).not.toBeDisabled();
});
});
describe('Interactions', () => {
it('should navigate to chat when Start Chat button is clicked', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />);
const startChatButton = screen.getByRole('button', { name: 'com_agents_start_chat' });
await user.click(startChatButton);
expect(mockNavigate).toHaveBeenCalledWith('/c/new?agent_id=test-agent-id');
});
it('should not navigate when agent is null', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} agent={null as any} />);
const startChatButton = screen.getByRole('button', { name: 'com_agents_start_chat' });
expect(startChatButton).toBeDisabled();
await user.click(startChatButton);
expect(mockNavigate).not.toHaveBeenCalled();
});
it('should open dropdown when 3-dot menu is clicked', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />);
const menuButton = screen.getByRole('button', { name: 'More options' });
await user.click(menuButton);
expect(screen.getByRole('button', { name: 'com_agents_copy_link' })).toBeInTheDocument();
});
it('should close dropdown when clicking outside', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />);
// Open dropdown
const menuButton = screen.getByRole('button', { name: 'More options' });
await user.click(menuButton);
expect(screen.getByRole('button', { name: 'com_agents_copy_link' })).toBeInTheDocument();
// Click outside (on the agent name)
const agentName = screen.getByText('Test Agent');
await user.click(agentName);
await waitFor(() => {
expect(
screen.queryByRole('button', { name: 'com_agents_copy_link' }),
).not.toBeInTheDocument();
});
});
it('should copy link and show success toast when Copy Link is clicked', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />);
// Open dropdown
const menuButton = screen.getByRole('button', { name: 'More options' });
await user.click(menuButton);
// Click copy link
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
await user.click(copyLinkButton);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
`${window.location.origin}/c/new?agent_id=test-agent-id`,
);
expect(mockShowToast).toHaveBeenCalledWith({
message: 'Link copied',
});
// Dropdown should close
await waitFor(() => {
expect(
screen.queryByRole('button', { name: 'com_agents_copy_link' }),
).not.toBeInTheDocument();
});
});
it('should show error toast when clipboard write fails', async () => {
const user = userEvent.setup();
(navigator.clipboard.writeText as jest.Mock).mockRejectedValue(new Error('Clipboard error'));
renderWithProviders(<AgentDetail {...defaultProps} />);
// Open dropdown and click copy link
const menuButton = screen.getByRole('button', { name: 'More options' });
await user.click(menuButton);
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
await user.click(copyLinkButton);
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: 'com_agents_link_copy_failed',
});
});
});
it('should call onClose when dialog is closed', () => {
const mockOnClose = jest.fn();
render(<AgentDetail {...defaultProps} onClose={mockOnClose} isOpen={false} />);
// Since we're testing the onOpenChange callback, we need to trigger it
// This would normally be done by the Dialog component when ESC is pressed or overlay is clicked
// We'll test this by checking that onClose is properly passed to the Dialog
expect(mockOnClose).toBeDefined();
});
});
describe('Accessibility', () => {
it('should have proper ARIA attributes', () => {
renderWithProviders(<AgentDetail {...defaultProps} />);
const menuButton = screen.getByRole('button', { name: 'More options' });
expect(menuButton).toHaveAttribute('aria-haspopup', 'menu');
expect(menuButton).toHaveAttribute('aria-label', 'More options');
});
it('should support keyboard navigation for dropdown', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />);
const menuButton = screen.getByRole('button', { name: 'More options' });
// Focus and open with Enter key
menuButton.focus();
await user.keyboard('{Enter}');
expect(screen.getByRole('button', { name: 'com_agents_copy_link' })).toBeInTheDocument();
});
it('should have proper focus management', async () => {
const user = userEvent.setup();
renderWithProviders(<AgentDetail {...defaultProps} />);
const menuButton = screen.getByRole('button', { name: 'More options' });
await user.click(menuButton);
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
expect(copyLinkButton).toHaveClass('focus:bg-surface-hover', 'focus:outline-none');
});
});
describe('Edge Cases', () => {
it('should handle agent with only email contact', () => {
const agentWithEmailOnly = {
...mockAgent,
support_contact: {
email: 'support@test.com',
},
};
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithEmailOnly} />);
expect(screen.getByRole('link', { name: 'support@test.com' })).toBeInTheDocument();
});
it('should handle agent with only name contact', () => {
const agentWithNameOnly = {
...mockAgent,
support_contact: {
name: 'Support Team',
},
};
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithNameOnly} />);
expect(screen.getByText('Support Team')).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('should handle very long description with proper text wrapping', () => {
const agentWithLongDescription = {
...mockAgent,
description:
'This is a very long description that should wrap properly and be displayed in multiple lines when the content exceeds the available width of the container.',
};
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithLongDescription} />);
const description = screen.getByText(agentWithLongDescription.description);
expect(description).toHaveClass('whitespace-pre-wrap');
});
it('should handle special characters in agent name', () => {
const agentWithSpecialChars = {
...mockAgent,
name: 'Test Agent™ & Co. (v2.0)',
};
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithSpecialChars} />);
expect(screen.getByText('Test Agent™ & Co. (v2.0)')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,339 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import AgentGrid from '../AgentGrid';
import { useDynamicAgentQuery } from '~/hooks/Agents';
import type t from 'librechat-data-provider';
// Mock the dynamic agent query hook
jest.mock('~/hooks/Agents', () => ({
useDynamicAgentQuery: jest.fn(),
}));
// Mock useLocalize hook
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
const mockTranslations: Record<string, string> = {
com_agents_top_picks: 'Top Picks',
com_agents_all: 'All Agents',
com_agents_recommended: 'Our recommended agents',
com_agents_results_for: 'Results for "{{query}}"',
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',
};
return mockTranslations[key] || key;
});
// Mock getCategoryDisplayName and getCategoryDescription
jest.mock('~/utils/agents', () => ({
getCategoryDisplayName: (category: string) => {
const names: Record<string, string> = {
promoted: 'Top Picks',
all: 'All',
general: 'General',
hr: 'HR',
finance: 'Finance',
};
return names[category] || category;
},
getCategoryDescription: (category: string) => {
const descriptions: Record<string, string> = {
promoted: 'Our recommended agents',
all: 'Browse all available agents',
general: 'General purpose agents',
hr: 'HR agents',
finance: 'Finance agents',
};
return descriptions[category] || '';
},
}));
const mockUseDynamicAgentQuery = useDynamicAgentQuery as jest.MockedFunction<
typeof useDynamicAgentQuery
>;
describe('AgentGrid Integration with useDynamicAgentQuery', () => {
const mockOnSelectAgent = jest.fn();
const mockAgents: Partial<t.Agent>[] = [
{
id: '1',
name: 'Test Agent 1',
description: 'First test agent',
avatar: '/avatar1.png',
},
{
id: '2',
name: 'Test Agent 2',
description: 'Second test agent',
avatar: { filepath: '/avatar2.png' },
},
];
const defaultMockQueryResult = {
data: {
agents: mockAgents,
pagination: {
current: 1,
hasMore: true,
total: 10,
},
},
isLoading: false,
error: null,
isFetching: false,
queryType: 'promoted' as const,
};
beforeEach(() => {
jest.clearAllMocks();
mockUseDynamicAgentQuery.mockReturnValue(defaultMockQueryResult);
});
describe('Query Integration', () => {
it('should call useDynamicAgentQuery with correct parameters', () => {
render(
<AgentGrid category="finance" searchQuery="test query" onSelectAgent={mockOnSelectAgent} />,
);
expect(mockUseDynamicAgentQuery).toHaveBeenCalledWith({
category: 'finance',
searchQuery: 'test query',
page: 1,
limit: 6,
});
});
it('should update page when "See More" is clicked', async () => {
render(<AgentGrid category="hr" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
const seeMoreButton = screen.getByText('See more');
fireEvent.click(seeMoreButton);
await waitFor(() => {
expect(mockUseDynamicAgentQuery).toHaveBeenCalledWith({
category: 'hr',
searchQuery: '',
page: 2,
limit: 6,
});
});
});
it('should reset page when category changes', () => {
const { rerender } = render(
<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />,
);
// Simulate clicking "See More" to increment page
const seeMoreButton = screen.getByText('See more');
fireEvent.click(seeMoreButton);
// Change category - should reset page to 1
rerender(<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(mockUseDynamicAgentQuery).toHaveBeenLastCalledWith({
category: 'finance',
searchQuery: '',
page: 1,
limit: 6,
});
});
it('should reset page when search query changes', () => {
const { rerender } = render(
<AgentGrid category="hr" searchQuery="" onSelectAgent={mockOnSelectAgent} />,
);
// Change search query - should reset page to 1
rerender(
<AgentGrid category="hr" searchQuery="new search" onSelectAgent={mockOnSelectAgent} />,
);
expect(mockUseDynamicAgentQuery).toHaveBeenLastCalledWith({
category: 'hr',
searchQuery: 'new search',
page: 1,
limit: 6,
});
});
});
describe('Different Query Types Display', () => {
it('should display correct title for promoted category', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
queryType: 'promoted',
});
render(<AgentGrid category="promoted" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('Top Picks')).toBeInTheDocument();
expect(screen.getByText('Our recommended agents')).toBeInTheDocument();
});
it('should display correct title for search results', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
queryType: 'search',
});
render(
<AgentGrid category="all" searchQuery="test search" onSelectAgent={mockOnSelectAgent} />,
);
expect(screen.getByText('Results for "test search"')).toBeInTheDocument();
});
it('should display correct title for specific category', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
queryType: 'category',
});
render(<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('Finance')).toBeInTheDocument();
expect(screen.getByText('Finance agents')).toBeInTheDocument();
});
it('should display correct title for all category', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
queryType: 'all',
});
render(<AgentGrid category="all" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('All')).toBeInTheDocument();
expect(screen.getByText('Browse all available agents')).toBeInTheDocument();
});
});
describe('Loading and Error States', () => {
it('should show loading skeleton when isLoading is true and no data', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
data: undefined,
isLoading: true,
});
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
// Should show loading skeletons
const loadingElements = screen.getAllByRole('generic');
const hasLoadingClass = loadingElements.some((el) => el.className.includes('animate-pulse'));
expect(hasLoadingClass).toBe(true);
});
it('should show error message when there is an error', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
data: undefined,
error: new Error('Test error'),
});
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('Error loading agents')).toBeInTheDocument();
});
it('should show loading spinner when fetching more data', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
isFetching: true,
});
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
// Should show agents and loading spinner for pagination
expect(screen.getByText('Test Agent 1')).toBeInTheDocument();
expect(screen.getByText('Test Agent 2')).toBeInTheDocument();
});
});
describe('Agent Interaction', () => {
it('should call onSelectAgent when agent card is clicked', () => {
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
const agentCard = screen.getByLabelText('Test Agent 1 agent card');
fireEvent.click(agentCard);
expect(mockOnSelectAgent).toHaveBeenCalledWith(mockAgents[0]);
});
});
describe('Pagination', () => {
it('should show "See More" button when hasMore is true', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
data: {
agents: mockAgents,
pagination: {
current: 1,
hasMore: true,
total: 10,
},
},
});
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('See more')).toBeInTheDocument();
});
it('should not show "See More" button when hasMore is false', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
data: {
agents: mockAgents,
pagination: {
current: 1,
hasMore: false,
total: 2,
},
},
});
render(<AgentGrid category="general" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.queryByText('See more')).not.toBeInTheDocument();
});
});
describe('Empty States', () => {
it('should show empty state for search results', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
data: {
agents: [],
pagination: { current: 1, hasMore: false, total: 0 },
},
queryType: 'search',
});
render(
<AgentGrid category="all" searchQuery="no results" onSelectAgent={mockOnSelectAgent} />,
);
expect(screen.getByText('No agents found. Try another search term.')).toBeInTheDocument();
});
it('should show empty state for category with no agents', () => {
mockUseDynamicAgentQuery.mockReturnValue({
...defaultMockQueryResult,
data: {
agents: [],
pagination: { current: 1, hasMore: false, total: 0 },
},
queryType: 'category',
});
render(<AgentGrid category="hr" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
expect(screen.getByText('No agents found in this category')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,229 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import CategoryTabs from '../CategoryTabs';
import type t from 'librechat-data-provider';
// Mock useLocalize hook
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
const mockTranslations: Record<string, string> = {
com_agents_top_picks: 'Top Picks',
com_agents_all: 'All',
com_agents_no_categories: 'No categories available',
com_agents_category_tabs_label: 'Agent Categories',
com_ui_agent_category_general: 'General',
com_ui_agent_category_hr: 'HR',
com_ui_agent_category_rd: 'R&D',
com_ui_agent_category_finance: 'Finance',
com_ui_agent_category_it: 'IT',
com_ui_agent_category_sales: 'Sales',
com_ui_agent_category_aftersales: 'After Sales',
};
return mockTranslations[key] || key;
});
describe('CategoryTabs', () => {
const mockCategories: t.TMarketplaceCategory[] = [
{ value: 'promoted', label: 'Top Picks', description: 'Our recommended agents', count: 5 },
{ value: 'all', label: 'All', description: 'All available agents', count: 20 },
{ value: 'general', label: 'General', description: 'General purpose agents', count: 8 },
{ value: 'hr', label: 'HR', description: 'HR agents', count: 3 },
{ value: 'finance', label: 'Finance', description: 'Finance agents', count: 4 },
];
const mockOnChange = jest.fn();
const user = userEvent.setup();
beforeEach(() => {
mockOnChange.mockClear();
});
it('renders provided categories', () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
// Check for provided categories
expect(screen.getByText('Top Picks')).toBeInTheDocument();
expect(screen.getByText('All')).toBeInTheDocument();
expect(screen.getByText('General')).toBeInTheDocument();
expect(screen.getByText('HR')).toBeInTheDocument();
expect(screen.getByText('Finance')).toBeInTheDocument();
});
it('handles loading state properly', () => {
render(
<CategoryTabs
categories={[]}
activeTab="promoted"
isLoading={true}
onChange={mockOnChange}
/>,
);
// SmartLoader should handle loading behavior correctly
// The component should render without crashing during loading
expect(screen.queryByText('No categories available')).not.toBeInTheDocument();
});
it('highlights the active tab', () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="general"
isLoading={false}
onChange={mockOnChange}
/>,
);
const generalTab = screen.getByText('General').closest('button');
expect(generalTab).toHaveClass('text-gray-900');
// Should have active underline
const underline = generalTab?.querySelector('.absolute.bottom-0');
expect(underline).toBeInTheDocument();
});
it('calls onChange when a tab is clicked', async () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
const hrTab = screen.getByText('HR');
await user.click(hrTab);
expect(mockOnChange).toHaveBeenCalledWith('hr');
});
it('handles promoted tab click correctly', async () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="general"
isLoading={false}
onChange={mockOnChange}
/>,
);
const topPicksTab = screen.getByText('Top Picks');
await user.click(topPicksTab);
expect(mockOnChange).toHaveBeenCalledWith('promoted');
});
it('handles all tab click correctly', async () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
const allTab = screen.getByText('All');
await user.click(allTab);
expect(mockOnChange).toHaveBeenCalledWith('all');
});
it('shows inactive state for non-selected tabs', () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
const generalTab = screen.getByText('General').closest('button');
expect(generalTab).toHaveClass('text-gray-600');
// Should not have active underline
const underline = generalTab?.querySelector('.absolute.bottom-0');
expect(underline).not.toBeInTheDocument();
});
it('renders with proper accessibility', () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
const tabs = screen.getAllByRole('tab');
expect(tabs.length).toBe(5);
// Verify all tabs are properly clickable buttons
tabs.forEach((tab) => {
expect(tab.tagName).toBe('BUTTON');
});
});
it('handles keyboard navigation', async () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
const generalTab = screen.getByText('General').closest('button')!;
// Focus the button and click it
generalTab.focus();
expect(document.activeElement).toBe(generalTab);
await user.click(generalTab);
expect(mockOnChange).toHaveBeenCalledWith('general');
});
it('shows empty state when categories prop is empty', () => {
render(
<CategoryTabs
categories={[]}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
// Should show empty state message (localized)
expect(screen.getByText('No categories available')).toBeInTheDocument();
});
it('maintains consistent ordering of categories', () => {
render(
<CategoryTabs
categories={mockCategories}
activeTab="promoted"
isLoading={false}
onChange={mockOnChange}
/>,
);
const tabs = screen.getAllByRole('tab');
const tabTexts = tabs.map((tab) => tab.textContent);
// Check that promoted is first and all is second
expect(tabTexts[0]).toBe('Top Picks');
expect(tabTexts[1]).toBe('All');
expect(tabTexts.length).toBe(5);
});
});

View file

@ -0,0 +1,302 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { ErrorDisplay } from '../ErrorDisplay';
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Mock the localize hook
const mockLocalize = jest.fn((key: string, options?: any) => {
const translations: Record<string, string> = {
com_agents_error_title: 'Something went wrong',
com_agents_error_generic: 'We encountered an issue while loading the content.',
com_agents_error_suggestion_generic: 'Please try refreshing the page or try again later.',
com_agents_error_network_title: 'Connection Problem',
com_agents_error_network_message: 'Unable to connect to the server.',
com_agents_error_network_suggestion: 'Check your internet connection and try again.',
com_agents_error_not_found_title: 'Not Found',
com_agents_error_not_found_message: 'The requested content could not be found.',
com_agents_error_not_found_suggestion:
'Try browsing other options or go back to the marketplace.',
com_agents_error_invalid_request: 'Invalid Request',
com_agents_error_bad_request_message: 'The request could not be processed.',
com_agents_error_bad_request_suggestion: 'Please check your input and try again.',
com_agents_error_server_title: 'Server Error',
com_agents_error_server_message: 'The server is temporarily unavailable.',
com_agents_error_server_suggestion: 'Please try again in a few moments.',
com_agents_error_search_title: 'Search Error',
com_agents_error_category_title: 'Category Error',
com_agents_search_no_results: `No agents found for "${options?.query}"`,
com_agents_category_empty: `No agents found in the ${options?.category} category`,
com_agents_error_retry: 'Try Again',
};
return translations[key] || key;
});
jest.mock('~/hooks/useLocalize', () => () => mockLocalize);
describe('ErrorDisplay', () => {
beforeEach(() => {
mockLocalize.mockClear();
});
describe('Backend error responses', () => {
it('displays user-friendly message from backend response', () => {
const error = {
response: {
data: {
userMessage: 'Unable to load agents. Please try refreshing the page.',
suggestion: 'Try refreshing the page or check your network connection',
},
},
};
render(<ErrorDisplay error={error} />);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(
screen.getByText('Unable to load agents. Please try refreshing the page.'),
).toBeInTheDocument();
expect(
screen.getByText('💡 Try refreshing the page or check your network connection'),
).toBeInTheDocument();
});
it('handles search context with backend response', () => {
const error = {
response: {
data: {
userMessage: 'Search is temporarily unavailable. Please try again.',
suggestion: 'Try a different search term or check your network connection',
},
},
};
render(<ErrorDisplay error={error} context={{ searchQuery: 'test query' }} />);
expect(screen.getByText('Search Error')).toBeInTheDocument();
expect(
screen.getByText('Search is temporarily unavailable. Please try again.'),
).toBeInTheDocument();
});
});
describe('Network errors', () => {
it('displays network error message', () => {
const error = {
code: 'NETWORK_ERROR',
message: 'Network Error',
};
render(<ErrorDisplay error={error} />);
expect(screen.getByText('Connection Problem')).toBeInTheDocument();
expect(screen.getByText('Unable to connect to the server.')).toBeInTheDocument();
expect(
screen.getByText('💡 Check your internet connection and try again.'),
).toBeInTheDocument();
});
it('handles timeout errors', () => {
const error = {
code: 'ECONNABORTED',
message: 'timeout of 5000ms exceeded',
};
render(<ErrorDisplay error={error} />);
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_timeout_title');
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_timeout_message');
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_timeout_suggestion');
});
});
describe('HTTP status codes', () => {
it('handles 404 errors with search context', () => {
const error = {
response: {
status: 404,
data: {},
},
};
render(<ErrorDisplay error={error} context={{ searchQuery: 'nonexistent agent' }} />);
expect(screen.getByText('Not Found')).toBeInTheDocument();
expect(screen.getByText('No agents found for "nonexistent agent"')).toBeInTheDocument();
});
it('handles 404 errors with category context', () => {
const error = {
response: {
status: 404,
data: {},
},
};
render(<ErrorDisplay error={error} context={{ category: 'productivity' }} />);
expect(screen.getByText('Not Found')).toBeInTheDocument();
expect(screen.getByText('No agents found in the productivity category')).toBeInTheDocument();
});
it('handles 400 bad request errors', () => {
const error = {
response: {
status: 400,
data: {
error: 'Search query is required',
userMessage: 'Please enter a search term to find agents',
suggestion: 'Enter a search term to find agents by name or description',
},
},
};
render(<ErrorDisplay error={error} />);
expect(screen.getByText('Invalid Request')).toBeInTheDocument();
expect(screen.getByText('Please enter a search term to find agents')).toBeInTheDocument();
expect(
screen.getByText('💡 Enter a search term to find agents by name or description'),
).toBeInTheDocument();
});
it('handles 500 server errors', () => {
const error = {
response: {
status: 500,
data: {},
},
};
render(<ErrorDisplay error={error} />);
expect(screen.getByText('Server Error')).toBeInTheDocument();
expect(screen.getByText('The server is temporarily unavailable.')).toBeInTheDocument();
expect(screen.getByText('💡 Please try again in a few moments.')).toBeInTheDocument();
});
});
describe('Retry functionality', () => {
it('displays retry button when onRetry is provided', () => {
const mockRetry = jest.fn();
const error = {
response: {
data: {
userMessage: 'Unable to load agents. Please try refreshing the page.',
},
},
};
render(<ErrorDisplay error={error} onRetry={mockRetry} />);
const retryButton = screen.getByText('Try Again');
expect(retryButton).toBeInTheDocument();
fireEvent.click(retryButton);
expect(mockRetry).toHaveBeenCalledTimes(1);
});
it('does not display retry button when onRetry is not provided', () => {
const error = {
response: {
data: {
userMessage: 'Unable to load agents. Please try refreshing the page.',
},
},
};
render(<ErrorDisplay error={error} />);
expect(screen.queryByText('Try Again')).not.toBeInTheDocument();
});
});
describe('Context-aware titles', () => {
it('shows search error title for search context', () => {
const error = { message: 'Some error' };
render(<ErrorDisplay error={error} context={{ searchQuery: 'test' }} />);
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_search_title');
});
it('shows category error title for category context', () => {
const error = { message: 'Some error' };
render(<ErrorDisplay error={error} context={{ category: 'productivity' }} />);
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_category_title');
});
it('shows generic error title when no context', () => {
const error = { message: 'Some error' };
render(<ErrorDisplay error={error} />);
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_title');
});
});
describe('Fallback error handling', () => {
it('handles unknown errors gracefully', () => {
const error = {
message: 'Unknown error occurred',
};
render(<ErrorDisplay error={error} />);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(
screen.getByText('We encountered an issue while loading the content.'),
).toBeInTheDocument();
expect(
screen.getByText('💡 Please try refreshing the page or try again later.'),
).toBeInTheDocument();
});
it('handles null/undefined errors', () => {
render(<ErrorDisplay error={null} />);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(
screen.getByText('We encountered an issue while loading the content.'),
).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('renders error icon with proper accessibility', () => {
const error = { message: 'Test error' };
render(<ErrorDisplay error={error} />);
const errorIcon = screen.getByRole('img', { hidden: true });
expect(errorIcon).toBeInTheDocument();
});
it('has proper heading structure', () => {
const error = { message: 'Test error' };
render(<ErrorDisplay error={error} />);
const heading = screen.getByRole('heading', { level: 3 });
expect(heading).toHaveTextContent('Something went wrong');
});
});
});
export default {};

View file

@ -0,0 +1,134 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { EModelEndpoint } from 'librechat-data-provider';
import { MarketplaceProvider } from '../MarketplaceContext';
import { useChatContext } from '~/Providers';
// Mock the ChatContext from Providers
jest.mock('~/Providers', () => ({
ChatContext: {
Provider: ({ children, value }: { children: React.ReactNode; value: any }) => (
<div data-testid="chat-context-provider" data-value={JSON.stringify(value)}>
{children}
</div>
),
},
useChatContext: jest.fn(),
}));
const mockedUseChatContext = useChatContext as jest.MockedFunction<typeof useChatContext>;
// Test component that consumes the context
const TestConsumer: React.FC = () => {
const context = mockedUseChatContext();
return (
<div>
<div data-testid="endpoint">{context?.conversation?.endpoint}</div>
<div data-testid="conversation-id">{context?.conversation?.conversationId}</div>
<div data-testid="title">{context?.conversation?.title}</div>
</div>
);
};
describe('MarketplaceProvider', () => {
beforeEach(() => {
mockedUseChatContext.mockClear();
});
it('provides correct marketplace context values', () => {
const mockContext = {
conversation: {
endpoint: EModelEndpoint.agents,
conversationId: 'marketplace',
title: 'Agent Marketplace',
},
};
mockedUseChatContext.mockReturnValue(mockContext);
render(
<MarketplaceProvider>
<TestConsumer />
</MarketplaceProvider>,
);
expect(screen.getByTestId('endpoint')).toHaveTextContent(EModelEndpoint.agents);
expect(screen.getByTestId('conversation-id')).toHaveTextContent('marketplace');
expect(screen.getByTestId('title')).toHaveTextContent('Agent Marketplace');
});
it('creates ChatContext.Provider with correct structure', () => {
render(
<MarketplaceProvider>
<div>{/* eslint-disable-line i18next/no-literal-string */}Test Child</div>
</MarketplaceProvider>,
);
const provider = screen.getByTestId('chat-context-provider');
expect(provider).toBeInTheDocument();
const valueData = JSON.parse(provider.getAttribute('data-value') || '{}');
expect(valueData.conversation).toEqual({
endpoint: EModelEndpoint.agents,
conversationId: 'marketplace',
title: 'Agent Marketplace',
});
});
it('renders children correctly', () => {
render(
<MarketplaceProvider>
<div data-testid="test-child">
{/* eslint-disable-line i18next/no-literal-string */}Test Content
</div>
</MarketplaceProvider>,
);
expect(screen.getByTestId('test-child')).toBeInTheDocument();
expect(screen.getByTestId('test-child')).toHaveTextContent('Test Content');
});
it('provides stable context value (memoization)', () => {
const { rerender } = render(
<MarketplaceProvider>
<TestConsumer />
</MarketplaceProvider>,
);
const firstProvider = screen.getByTestId('chat-context-provider');
const firstValue = firstProvider.getAttribute('data-value');
// Rerender should provide the same memoized value
rerender(
<MarketplaceProvider>
<TestConsumer />
</MarketplaceProvider>,
);
const secondProvider = screen.getByTestId('chat-context-provider');
const secondValue = secondProvider.getAttribute('data-value');
expect(firstValue).toBe(secondValue);
});
it('provides minimal context without bloated functions', () => {
render(
<MarketplaceProvider>
<div>{/* eslint-disable-line i18next/no-literal-string */}Test</div>
</MarketplaceProvider>,
);
const provider = screen.getByTestId('chat-context-provider');
const valueData = JSON.parse(provider.getAttribute('data-value') || '{}');
// Should only have conversation object, not 44 empty functions
expect(Object.keys(valueData)).toContain('conversation');
expect(valueData.conversation).toEqual({
endpoint: EModelEndpoint.agents,
conversationId: 'marketplace',
title: 'Agent Marketplace',
});
});
});

View file

@ -0,0 +1,141 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import SearchBar from '../SearchBar';
// Mock useLocalize hook
jest.mock('~/hooks/useLocalize', () => () => (key: string) => key);
// Mock useDebounce hook
jest.mock('~/hooks', () => ({
useDebounce: (value: string, delay: number) => value, // Return value immediately for testing
}));
describe('SearchBar', () => {
const mockOnSearch = jest.fn();
const user = userEvent.setup();
beforeEach(() => {
mockOnSearch.mockClear();
});
it('renders with correct placeholder', () => {
render(<SearchBar value="" onSearch={mockOnSearch} />);
const input = screen.getByRole('textbox');
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('placeholder', 'com_agents_search_placeholder');
});
it('displays the provided value', () => {
render(<SearchBar value="test query" onSearch={mockOnSearch} />);
const input = screen.getByDisplayValue('test query');
expect(input).toBeInTheDocument();
});
it('calls onSearch when user types', async () => {
render(<SearchBar value="" onSearch={mockOnSearch} />);
const input = screen.getByRole('textbox');
await user.type(input, 'test');
// Should call onSearch for each character due to debounce mock
expect(mockOnSearch).toHaveBeenCalled();
});
it('shows clear button when there is text', () => {
render(<SearchBar value="test" onSearch={mockOnSearch} />);
const clearButton = screen.getByRole('button', { name: 'com_agents_clear_search' });
expect(clearButton).toBeInTheDocument();
});
it('does not show clear button when text is empty', () => {
render(<SearchBar value="" onSearch={mockOnSearch} />);
const clearButton = screen.queryByRole('button', { name: 'com_agents_clear_search' });
expect(clearButton).not.toBeInTheDocument();
});
it('clears search when clear button is clicked', async () => {
render(<SearchBar value="test" onSearch={mockOnSearch} />);
const input = screen.getByRole('textbox');
const clearButton = screen.getByRole('button', { name: 'com_agents_clear_search' });
// Verify initial state
expect(input).toHaveValue('test');
await user.click(clearButton);
// Verify onSearch is called and input is cleared
expect(mockOnSearch).toHaveBeenCalledWith('');
expect(input).toHaveValue('');
});
it('updates internal state when value prop changes', () => {
const { rerender } = render(<SearchBar value="initial" onSearch={mockOnSearch} />);
expect(screen.getByDisplayValue('initial')).toBeInTheDocument();
rerender(<SearchBar value="updated" onSearch={mockOnSearch} />);
expect(screen.getByDisplayValue('updated')).toBeInTheDocument();
});
it('has proper accessibility attributes', () => {
render(<SearchBar value="" onSearch={mockOnSearch} />);
const input = screen.getByRole('textbox');
expect(input).toHaveAttribute('aria-label', 'com_agents_search_aria');
});
it('applies custom className', () => {
render(<SearchBar value="" onSearch={mockOnSearch} className="custom-class" />);
const container = screen.getByRole('textbox').closest('div');
expect(container).toHaveClass('custom-class');
});
it('prevents form submission on clear button click', async () => {
const handleSubmit = jest.fn();
render(
<form onSubmit={handleSubmit}>
<SearchBar value="test" onSearch={mockOnSearch} />
</form>,
);
const clearButton = screen.getByRole('button', { name: 'com_agents_clear_search' });
await user.click(clearButton);
expect(handleSubmit).not.toHaveBeenCalled();
});
it('handles rapid typing correctly', async () => {
render(<SearchBar value="" onSearch={mockOnSearch} />);
const input = screen.getByRole('textbox');
// Type multiple characters quickly
await user.type(input, 'quick');
// Should handle all characters
expect(input).toHaveValue('quick');
});
it('maintains focus after clear button click', async () => {
render(<SearchBar value="test" onSearch={mockOnSearch} />);
const input = screen.getByRole('textbox');
const clearButton = screen.getByRole('button', { name: 'com_agents_clear_search' });
input.focus();
await user.click(clearButton);
// Input should still be in the document and ready for new input
expect(input).toBeInTheDocument();
});
});

View file

@ -0,0 +1,370 @@
import React from 'react';
import { render, screen, waitFor, act } from '@testing-library/react';
import { SmartLoader, useHasData } from '../SmartLoader';
// Mock setTimeout and clearTimeout for testing
jest.useFakeTimers();
describe('SmartLoader', () => {
const LoadingComponent = () => <div data-testid="loading">Loading...</div>;
const ContentComponent = () => (
<div data-testid="content">
{/* eslint-disable-line i18next/no-literal-string */}Content loaded
</div>
);
beforeEach(() => {
jest.clearAllTimers();
});
afterEach(() => {
jest.useRealTimers();
jest.useFakeTimers();
});
describe('Basic functionality', () => {
it('shows content immediately when not loading', () => {
render(
<SmartLoader isLoading={false} hasData={true} loadingComponent={<LoadingComponent />}>
<ContentComponent />
</SmartLoader>,
);
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
it('shows content immediately when loading but has existing data', () => {
render(
<SmartLoader isLoading={true} hasData={true} loadingComponent={<LoadingComponent />}>
<ContentComponent />
</SmartLoader>,
);
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
it('shows content initially, then loading after delay when loading with no data', async () => {
render(
<SmartLoader
isLoading={true}
hasData={false}
delay={150}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Initially shows content
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
// After delay, shows loading
act(() => {
jest.advanceTimersByTime(150);
});
await waitFor(() => {
expect(screen.getByTestId('loading')).toBeInTheDocument();
expect(screen.queryByTestId('content')).not.toBeInTheDocument();
});
});
it('prevents loading flash for quick responses', async () => {
const { rerender } = render(
<SmartLoader
isLoading={true}
hasData={false}
delay={150}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Initially shows content
expect(screen.getByTestId('content')).toBeInTheDocument();
// Advance time but not past delay
act(() => {
jest.advanceTimersByTime(100);
});
// Loading finishes before delay
rerender(
<SmartLoader
isLoading={false}
hasData={true}
delay={150}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Should still show content, never showed loading
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
// Advance past original delay to ensure loading doesn't appear
act(() => {
jest.advanceTimersByTime(100);
});
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
});
describe('Delay behavior', () => {
it('respects custom delay times', async () => {
render(
<SmartLoader
isLoading={true}
hasData={false}
delay={300}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Should show content initially
expect(screen.getByTestId('content')).toBeInTheDocument();
// Should not show loading before delay
act(() => {
jest.advanceTimersByTime(250);
});
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
// Should show loading after delay
act(() => {
jest.advanceTimersByTime(60);
});
await waitFor(() => {
expect(screen.getByTestId('loading')).toBeInTheDocument();
});
});
it('uses default delay when not specified', async () => {
render(
<SmartLoader isLoading={true} hasData={false} loadingComponent={<LoadingComponent />}>
<ContentComponent />
</SmartLoader>,
);
// Should show content initially
expect(screen.getByTestId('content')).toBeInTheDocument();
// Should show loading after default delay (150ms)
act(() => {
jest.advanceTimersByTime(150);
});
await waitFor(() => {
expect(screen.getByTestId('loading')).toBeInTheDocument();
});
});
});
describe('State transitions', () => {
it('immediately hides loading when loading completes', async () => {
const { rerender } = render(
<SmartLoader
isLoading={true}
hasData={false}
delay={100}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Advance past delay to show loading
act(() => {
jest.advanceTimersByTime(100);
});
await waitFor(() => {
expect(screen.getByTestId('loading')).toBeInTheDocument();
});
// Loading completes
rerender(
<SmartLoader
isLoading={false}
hasData={true}
delay={100}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Should immediately show content
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
it('handles rapid loading state changes correctly', async () => {
const { rerender } = render(
<SmartLoader
isLoading={true}
hasData={false}
delay={100}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Rapid state changes
rerender(
<SmartLoader
isLoading={false}
hasData={true}
delay={100}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
rerender(
<SmartLoader
isLoading={true}
hasData={false}
delay={100}
loadingComponent={<LoadingComponent />}
>
<ContentComponent />
</SmartLoader>,
);
// Should show content throughout rapid changes
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
});
describe('CSS classes', () => {
it('applies custom className', () => {
const { container } = render(
<SmartLoader
isLoading={false}
hasData={true}
loadingComponent={<LoadingComponent />}
className="custom-class"
>
<ContentComponent />
</SmartLoader>,
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass('custom-class');
});
it('applies className to both loading and content states', async () => {
const { container } = render(
<SmartLoader
isLoading={true}
hasData={false}
delay={50}
loadingComponent={<LoadingComponent />}
className="custom-class"
>
<ContentComponent />
</SmartLoader>,
);
// Content state
expect(container.firstChild).toHaveClass('custom-class');
// Loading state
act(() => {
jest.advanceTimersByTime(50);
});
await waitFor(() => {
expect(container.firstChild).toHaveClass('custom-class');
});
});
});
});
describe('useHasData', () => {
const TestComponent: React.FC<{ data: any }> = ({ data }) => {
const hasData = useHasData(data);
return <div data-testid="result">{hasData ? 'has-data' : 'no-data'}</div>;
};
it('returns false for null data', () => {
render(<TestComponent data={null} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('returns false for undefined data', () => {
render(<TestComponent data={undefined} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('detects empty agents array as no data', () => {
render(<TestComponent data={{ agents: [] }} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('detects non-empty agents array as has data', () => {
render(<TestComponent data={{ agents: [{ id: '1', name: 'Test' }] }} />);
expect(screen.getByTestId('result')).toHaveTextContent('has-data');
});
it('detects invalid agents property as no data', () => {
render(<TestComponent data={{ agents: 'not-array' }} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('detects empty array as no data', () => {
render(<TestComponent data={[]} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('detects non-empty array as has data', () => {
render(<TestComponent data={[{ name: 'category1' }]} />);
expect(screen.getByTestId('result')).toHaveTextContent('has-data');
});
it('detects agent with id as has data', () => {
render(<TestComponent data={{ id: '123', name: 'Test Agent' }} />);
expect(screen.getByTestId('result')).toHaveTextContent('has-data');
});
it('detects agent with name only as has data', () => {
render(<TestComponent data={{ name: 'Test Agent' }} />);
expect(screen.getByTestId('result')).toHaveTextContent('has-data');
});
it('detects object without id or name as no data', () => {
render(<TestComponent data={{ description: 'Some description' }} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('handles string data as no data', () => {
render(<TestComponent data="some string" />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('handles number data as no data', () => {
render(<TestComponent data={42} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
it('handles boolean data as no data', () => {
render(<TestComponent data={true} />);
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
});
});

View file

@ -0,0 +1,12 @@
import { TranslationKeys } from '~/hooks/useLocalize';
export interface AgentCategory {
label: TranslationKeys;
value: string;
}
// The empty category placeholder - used for form defaults
export const EMPTY_AGENT_CATEGORY: AgentCategory = {
value: '',
label: 'com_ui_agent_category_general',
};

View file

@ -75,3 +75,140 @@ export const useGetAgentByIdQuery = (
},
);
};
/**
* MARKETPLACE QUERIES
*/
/**
* Hook for getting all agent categories with counts
*/
export const useGetAgentCategoriesQuery = <TData = t.TMarketplaceCategory[]>(
config?: UseQueryOptions<t.TMarketplaceCategory[], unknown, TData>,
): QueryObserverResult<TData> => {
const queryClient = useQueryClient();
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
const enabled = !!endpointsConfig?.[EModelEndpoint.agents];
return useQuery<t.TMarketplaceCategory[], unknown, TData>(
[QueryKeys.agents, 'categories'],
() => dataService.getAgentCategories(),
{
staleTime: 1000 * 60 * 15, // 15 minutes - categories rarely change
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: false,
...config,
enabled: config?.enabled !== undefined ? config.enabled && enabled : enabled,
},
);
};
/**
* Hook for getting promoted/top picks agents with pagination
*/
export const useGetPromotedAgentsQuery = <TData = t.AgentListResponse>(
params: { page?: number; limit?: number; showAll?: string } = { page: 1, limit: 6 },
config?: UseQueryOptions<t.AgentListResponse, unknown, TData>,
): QueryObserverResult<TData> => {
const queryClient = useQueryClient();
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
const enabled = !!endpointsConfig?.[EModelEndpoint.agents];
return useQuery<t.AgentListResponse, unknown, TData>(
[QueryKeys.agents, 'promoted', params],
() => dataService.getPromotedAgents(params),
{
staleTime: 1000 * 60, // 1 minute stale time
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: false,
keepPreviousData: true,
...config,
enabled: config?.enabled !== undefined ? config.enabled && enabled : enabled,
},
);
};
/**
* Hook for getting all agents with pagination (for "all" category)
*/
export const useGetAllAgentsQuery = <TData = t.AgentListResponse>(
params: { page?: number; limit?: number } = { page: 1, limit: 6 },
config?: UseQueryOptions<t.AgentListResponse, unknown, TData>,
): QueryObserverResult<TData> => {
const queryClient = useQueryClient();
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
const enabled = !!endpointsConfig?.[EModelEndpoint.agents];
return useQuery<t.AgentListResponse, unknown, TData>(
[QueryKeys.agents, 'all', params],
() => dataService.getAllAgents(params),
{
staleTime: 1000 * 60, // 1 minute stale time
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: false,
keepPreviousData: true,
...config,
enabled: config?.enabled !== undefined ? config.enabled && enabled : enabled,
},
);
};
/**
* Hook for getting agents by category with pagination
*/
export const useGetAgentsByCategoryQuery = <TData = t.AgentListResponse>(
params: { category: string; page?: number; limit?: number },
config?: UseQueryOptions<t.AgentListResponse, unknown, TData>,
): QueryObserverResult<TData> => {
const queryClient = useQueryClient();
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
const enabled = !!endpointsConfig?.[EModelEndpoint.agents];
return useQuery<t.AgentListResponse, unknown, TData>(
[QueryKeys.agents, 'category', params],
() => dataService.getAgentsByCategory(params),
{
staleTime: 1000 * 60, // 1 minute stale time
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: false,
keepPreviousData: true,
...config,
enabled: config?.enabled !== undefined ? config.enabled && enabled : enabled,
},
);
};
/**
* Hook for searching agents with pagination and filtering
*/
export const useSearchAgentsQuery = <TData = t.AgentListResponse>(
params: { q: string; category?: string; page?: number; limit?: number },
config?: UseQueryOptions<t.AgentListResponse, unknown, TData>,
): QueryObserverResult<TData> => {
const queryClient = useQueryClient();
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
const enabled = !!endpointsConfig?.[EModelEndpoint.agents] && !!params.q;
return useQuery<t.AgentListResponse, unknown, TData>(
[QueryKeys.agents, 'search', params],
() => dataService.searchAgents(params),
{
staleTime: 1000 * 60, // 1 minute stale time
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: false,
keepPreviousData: true,
...config,
enabled: config?.enabled !== undefined ? config.enabled && enabled : enabled,
},
);
};

View file

@ -0,0 +1,35 @@
import { renderHook } from '@testing-library/react';
import useAgentCategories from '../useAgentCategories';
import { AGENT_CATEGORIES, EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories';
// Mock the useLocalize hook
jest.mock('~/hooks/useLocalize', () => ({
__esModule: true,
default: () => (key: string) => {
// Simple mock implementation that returns the key as the translation
return key === 'com_ui_agent_category_general' ? 'General (Translated)' : key;
},
}));
describe('useAgentCategories', () => {
it('should return processed categories with correct structure', () => {
const { result } = renderHook(() => useAgentCategories());
// Check that we have the expected number of categories
expect(result.current.categories.length).toBe(AGENT_CATEGORIES.length);
// Check that the first category has the expected structure
const firstCategory = result.current.categories[0];
const firstOriginalCategory = AGENT_CATEGORIES[0];
expect(firstCategory.value).toBe(firstOriginalCategory.value);
// Check that labels are properly translated
expect(firstCategory.label).toBe('General (Translated)');
expect(firstCategory.className).toBe('w-full');
// Check the empty category
expect(result.current.emptyCategory.value).toBe(EMPTY_AGENT_CATEGORY.value);
expect(result.current.emptyCategory.label).toBeTruthy();
});
});

View file

@ -0,0 +1,360 @@
import { renderHook } from '@testing-library/react';
import { useDynamicAgentQuery } from '../useDynamicAgentQuery';
import {
useGetPromotedAgentsQuery,
useGetAgentsByCategoryQuery,
useSearchAgentsQuery,
} from '~/data-provider';
// Mock the data provider queries
jest.mock('~/data-provider', () => ({
useGetPromotedAgentsQuery: jest.fn(),
useGetAgentsByCategoryQuery: jest.fn(),
useSearchAgentsQuery: jest.fn(),
}));
const mockUseGetPromotedAgentsQuery = useGetPromotedAgentsQuery as jest.MockedFunction<
typeof useGetPromotedAgentsQuery
>;
const mockUseGetAgentsByCategoryQuery = useGetAgentsByCategoryQuery as jest.MockedFunction<
typeof useGetAgentsByCategoryQuery
>;
const mockUseSearchAgentsQuery = useSearchAgentsQuery as jest.MockedFunction<
typeof useSearchAgentsQuery
>;
describe('useDynamicAgentQuery', () => {
const defaultMockQueryResult = {
data: undefined,
isLoading: false,
error: null,
isFetching: false,
refetch: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
// Set default mock returns
mockUseGetPromotedAgentsQuery.mockReturnValue(defaultMockQueryResult as any);
mockUseGetAgentsByCategoryQuery.mockReturnValue(defaultMockQueryResult as any);
mockUseSearchAgentsQuery.mockReturnValue(defaultMockQueryResult as any);
});
describe('Search Query Type', () => {
it('should use search query when searchQuery is provided', () => {
const mockSearchResult = {
...defaultMockQueryResult,
data: { agents: [], pagination: { hasMore: false } },
};
mockUseSearchAgentsQuery.mockReturnValue(mockSearchResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'hr',
searchQuery: 'test search',
page: 1,
limit: 6,
}),
);
// Should call search query with correct parameters
expect(mockUseSearchAgentsQuery).toHaveBeenCalledWith(
{
q: 'test search',
category: 'hr',
page: 1,
limit: 6,
},
expect.objectContaining({
enabled: true,
staleTime: 120000,
refetchOnWindowFocus: false,
keepPreviousData: true,
refetchOnMount: false,
refetchOnReconnect: false,
retry: 1,
}),
);
// Should return search query result
expect(result.current.data).toBe(mockSearchResult.data);
expect(result.current.queryType).toBe('search');
});
it('should not include category in search when category is "all" or "promoted"', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'all',
searchQuery: 'test search',
page: 1,
limit: 6,
}),
);
expect(mockUseSearchAgentsQuery).toHaveBeenCalledWith(
{
q: 'test search',
page: 1,
limit: 6,
// No category parameter should be included
},
expect.any(Object),
);
});
});
describe('Promoted Query Type', () => {
it('should use promoted query when category is "promoted" and no search', () => {
const mockPromotedResult = {
...defaultMockQueryResult,
data: { agents: [], pagination: { hasMore: false } },
};
mockUseGetPromotedAgentsQuery.mockReturnValue(mockPromotedResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'promoted',
searchQuery: '',
page: 2,
limit: 8,
}),
);
// Should call promoted query with correct parameters (no showAll)
expect(mockUseGetPromotedAgentsQuery).toHaveBeenCalledWith(
{
page: 2,
limit: 8,
},
expect.objectContaining({
enabled: true,
}),
);
expect(result.current.data).toBe(mockPromotedResult.data);
expect(result.current.queryType).toBe('promoted');
});
});
describe('All Agents Query Type', () => {
it('should use promoted query with showAll when category is "all" and no search', () => {
const mockAllResult = {
...defaultMockQueryResult,
data: { agents: [], pagination: { hasMore: false } },
};
// Mock the second call to useGetPromotedAgentsQuery (for "all" category)
mockUseGetPromotedAgentsQuery
.mockReturnValueOnce(defaultMockQueryResult as any) // First call for promoted
.mockReturnValueOnce(mockAllResult as any); // Second call for all
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'all',
searchQuery: '',
page: 1,
limit: 6,
}),
);
// Should call promoted query with showAll parameter
expect(mockUseGetPromotedAgentsQuery).toHaveBeenCalledWith(
{
page: 1,
limit: 6,
showAll: 'true',
},
expect.objectContaining({
enabled: true,
}),
);
expect(result.current.queryType).toBe('all');
});
});
describe('Category Query Type', () => {
it('should use category query for specific categories', () => {
const mockCategoryResult = {
...defaultMockQueryResult,
data: { agents: [], pagination: { hasMore: false } },
};
mockUseGetAgentsByCategoryQuery.mockReturnValue(mockCategoryResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'finance',
searchQuery: '',
page: 3,
limit: 10,
}),
);
expect(mockUseGetAgentsByCategoryQuery).toHaveBeenCalledWith(
{
category: 'finance',
page: 3,
limit: 10,
},
expect.objectContaining({
enabled: true,
}),
);
expect(result.current.data).toBe(mockCategoryResult.data);
expect(result.current.queryType).toBe('category');
});
});
describe('Query Configuration', () => {
it('should apply correct query configuration to all queries', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'hr',
searchQuery: '',
page: 1,
limit: 6,
}),
);
const expectedConfig = expect.objectContaining({
staleTime: 120000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: 1,
keepPreviousData: true,
});
expect(mockUseGetAgentsByCategoryQuery).toHaveBeenCalledWith(
expect.any(Object),
expectedConfig,
);
});
it('should enable only the correct query based on query type', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'hr',
searchQuery: '',
page: 1,
limit: 6,
}),
);
// Category query should be enabled
expect(mockUseGetAgentsByCategoryQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: true }),
);
// Other queries should be disabled
expect(mockUseSearchAgentsQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: false }),
);
expect(mockUseGetPromotedAgentsQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: false }),
);
});
});
describe('Default Parameters', () => {
it('should use default page and limit when not provided', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'general',
searchQuery: '',
}),
);
expect(mockUseGetAgentsByCategoryQuery).toHaveBeenCalledWith(
{
category: 'general',
page: 1,
limit: 6,
},
expect.any(Object),
);
});
});
describe('Return Values', () => {
it('should return all necessary query properties', () => {
const mockResult = {
data: { agents: [{ id: '1', name: 'Test Agent' }] },
isLoading: true,
error: null,
isFetching: false,
refetch: jest.fn(),
};
mockUseGetAgentsByCategoryQuery.mockReturnValue(mockResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'it',
searchQuery: '',
page: 1,
limit: 6,
}),
);
expect(result.current).toEqual({
data: mockResult.data,
isLoading: mockResult.isLoading,
error: mockResult.error,
isFetching: mockResult.isFetching,
refetch: mockResult.refetch,
queryType: 'category',
});
});
});
describe('Edge Cases', () => {
it('should handle empty search query as no search', () => {
renderHook(() =>
useDynamicAgentQuery({
category: 'promoted',
searchQuery: '', // Empty string should not trigger search
page: 1,
limit: 6,
}),
);
// Should use promoted query, not search query
expect(mockUseGetPromotedAgentsQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: true }),
);
expect(mockUseSearchAgentsQuery).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ enabled: false }),
);
});
it('should fallback to promoted query for unknown query types', () => {
const mockPromotedResult = {
...defaultMockQueryResult,
data: { agents: [] },
};
mockUseGetPromotedAgentsQuery.mockReturnValue(mockPromotedResult as any);
const { result } = renderHook(() =>
useDynamicAgentQuery({
category: 'unknown-category',
searchQuery: '',
page: 1,
limit: 6,
}),
);
// Should determine this as 'category' type and use category query
expect(result.current.queryType).toBe('category');
});
});
});

View file

@ -1,2 +1,5 @@
export { default as useAgentsMap } from './useAgentsMap';
export { default as useSelectAgent } from './useSelectAgent';
export { default as useAgentCategories } from './useAgentCategories';
export { useDynamicAgentQuery } from './useDynamicAgentQuery';
export type { ProcessedAgentCategory } from './useAgentCategories';

View file

@ -0,0 +1,57 @@
import { useMemo } from 'react';
import useLocalize from '~/hooks/useLocalize';
import { useGetAgentCategoriesQuery } from '~/data-provider';
import { EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories';
// This interface matches the structure used by the ControlCombobox component
export interface ProcessedAgentCategory {
label: string; // Translated label
value: string; // Category value
className?: string;
}
/**
* Custom hook that provides processed and translated agent categories from API
*
* @returns Object containing categories, emptyCategory, and loading state
*/
const useAgentCategories = () => {
const localize = useLocalize();
// Fetch categories from API
const categoriesQuery = useGetAgentCategoriesQuery({
staleTime: 1000 * 60 * 15, // 15 minutes
});
const categories = useMemo((): ProcessedAgentCategory[] => {
if (!categoriesQuery.data) return [];
// Filter out special categories (promoted, all) and convert to form format
return categoriesQuery.data
.filter((category) => category.value !== 'promoted' && category.value !== 'all')
.map((category) => ({
label: category.label || category.value,
value: category.value,
className: 'w-full',
}));
}, [categoriesQuery.data]);
const emptyCategory = useMemo(
(): ProcessedAgentCategory => ({
label: localize(EMPTY_AGENT_CATEGORY.label),
value: EMPTY_AGENT_CATEGORY.value,
className: 'w-full',
}),
[localize],
);
return {
categories,
emptyCategory,
isLoading: categoriesQuery.isLoading,
error: categoriesQuery.error,
};
};
export default useAgentCategories;

View file

@ -0,0 +1,112 @@
import { useMemo } from 'react';
import type { UseQueryOptions } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
import {
useGetPromotedAgentsQuery,
useGetAgentsByCategoryQuery,
useSearchAgentsQuery,
} from '~/data-provider';
interface UseDynamicAgentQueryParams {
category: string;
searchQuery: string;
page?: number;
limit?: number;
}
/**
* Single dynamic query hook that replaces 4 separate conditional queries
* Determines the appropriate query based on category and search state
*/
export const useDynamicAgentQuery = ({
category,
searchQuery,
page = 1,
limit = 6,
}: UseDynamicAgentQueryParams) => {
// Shared query configuration optimized to prevent unnecessary loading states
const queryConfig: UseQueryOptions<t.AgentListResponse> = useMemo(
() => ({
staleTime: 1000 * 60 * 2, // 2 minutes - agents don't change frequently
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: 1,
keepPreviousData: true,
// Removed placeholderData due to TypeScript compatibility - keepPreviousData is sufficient
}),
[],
);
// Determine query type and parameters based on current state
const queryType = useMemo(() => {
if (searchQuery) return 'search';
if (category === 'promoted') return 'promoted';
if (category === 'all') return 'all';
return 'category';
}, [category, searchQuery]);
// Search query - when user is searching
const searchQuery_result = useSearchAgentsQuery(
{
q: searchQuery,
...(category !== 'all' && category !== 'promoted' && { category }),
page,
limit,
},
{
...queryConfig,
enabled: queryType === 'search',
},
);
// Promoted agents query - for "Top Picks" tab
const promotedQuery = useGetPromotedAgentsQuery(
{ page, limit },
{
...queryConfig,
enabled: queryType === 'promoted',
},
);
// All agents query - for "All" tab (promoted endpoint with showAll parameter)
const allAgentsQuery = useGetPromotedAgentsQuery(
{ page, limit, showAll: 'true' },
{
...queryConfig,
enabled: queryType === 'all',
},
);
// Category-specific query - for individual categories
const categoryQuery = useGetAgentsByCategoryQuery(
{ category, page, limit },
{
...queryConfig,
enabled: queryType === 'category',
},
);
// Return the active query based on current state
const activeQuery = useMemo(() => {
switch (queryType) {
case 'search':
return searchQuery_result;
case 'promoted':
return promotedQuery;
case 'all':
return allAgentsQuery;
case 'category':
return categoryQuery;
default:
return promotedQuery; // fallback
}
}, [queryType, searchQuery_result, promotedQuery, allAgentsQuery, categoryQuery]);
return {
...activeQuery,
queryType, // Expose query type for debugging/logging
};
};

View file

@ -1,5 +1,11 @@
import { useMemo } from 'react';
import { MessageSquareQuote, ArrowRightToLine, Settings2, Database, Bookmark } from 'lucide-react';
import {
MessageSquareQuote,
ArrowRightToLine,
Settings2, Database,
Bookmark,
LayoutGrid,
} from 'lucide-react';
import {
isAssistantsEndpoint,
isAgentsEndpoint,
@ -21,6 +27,7 @@ import FilesPanel from '~/components/SidePanel/Files/Panel';
import MCPPanel from '~/components/SidePanel/MCP/MCPPanel';
import { useGetStartupConfig } from '~/data-provider';
import { useHasAccess } from '~/hooks';
import { useNavigate } from 'react-router-dom';
export default function useSideNavLinks({
hidePanel,
@ -37,6 +44,7 @@ export default function useSideNavLinks({
interfaceConfig: Partial<TInterfaceConfig>;
endpointsConfig: TEndpointsConfig;
}) {
const navigate = useNavigate();
const hasAccessToPrompts = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.USE,

View file

@ -37,7 +37,7 @@
"com_assistants_actions_info": "Let your Assistant retrieve information or take actions via API's",
"com_assistants_add_actions": "Add Actions",
"com_assistants_add_tools": "Add Tools",
"com_assistants_allow_sites_you_trust": "Only allow sites you trust.",
"com_assistants_allow_sites_you_trust": "Only allow sites you trust..",
"com_assistants_append_date": "Append Current Date & Time",
"com_assistants_append_date_tooltip": "When enabled, the current client date and time will be appended to the assistant system instructions.",
"com_assistants_attempt_info": "Assistant wants to send the following:",
@ -617,6 +617,14 @@
"com_ui_cancel": "Cancel",
"com_ui_cancelled": "Cancelled",
"com_ui_category": "Category",
"com_ui_agent_category_selector_aria": "Agent's category selector",
"com_ui_agent_category_general": "General",
"com_ui_agent_category_hr": "HR",
"com_ui_agent_category_rd": "R&D",
"com_ui_agent_category_finance": "Finance",
"com_ui_agent_category_it": "IT",
"com_ui_agent_category_sales": "Sales",
"com_ui_agent_category_aftersales": "After Sales",
"com_ui_chat": "Chat",
"com_ui_chat_history": "Chat History",
"com_ui_clear": "Clear",
@ -933,6 +941,7 @@
"com_ui_scope": "Scope",
"com_ui_search": "Search",
"com_ui_seconds": "seconds",
"com_ui_search_agent_category": "Search categories...",
"com_ui_secret_key": "Secret Key",
"com_ui_select": "Select",
"com_ui_select_all": "Select All",
@ -977,6 +986,13 @@
"com_ui_stop": "Stop",
"com_ui_storage": "Storage",
"com_ui_submit": "Submit",
"com_ui_support_contact": "Support Contact",
"com_ui_support_contact_name": "Name",
"com_ui_support_contact_name_placeholder": "Support contact name",
"com_ui_support_contact_name_min_length": "Name must be at least {{minLength}} characters",
"com_ui_support_contact_email": "Email",
"com_ui_support_contact_email_placeholder": "support@example.com",
"com_ui_support_contact_email_invalid": "Please enter a valid email address",
"com_ui_teach_or_explain": "Learning",
"com_ui_temporary": "Temporary Chat",
"com_ui_terms_and_conditions": "Terms and Conditions",
@ -1055,5 +1071,59 @@
"com_ui_yes": "Yes",
"com_ui_zoom": "Zoom",
"com_user_message": "You",
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint."
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint.",
"com_agents_marketplace": "Agent Marketplace",
"com_agents_all": "All Agents",
"com_agents_top_picks": "Top Picks",
"com_agents_recommended": "Our recommended agents",
"com_agents_search_placeholder": "Search agents...",
"com_agents_search_aria": "Search for agents",
"com_agents_clear_search": "Clear search",
"com_agents_see_more": "See more",
"com_agents_created_by": "by",
"com_agents_contact": "Contact",
"com_agents_start_chat": "Start Chat",
"com_agents_copy_link": "Copy Link",
"com_agents_link_copied": "Link copied",
"com_agents_link_copy_failed": "Failed to copy link",
"com_agents_more_options": "More options",
"com_agents_close": "Close",
"com_agents_loading": "Loading...",
"com_agents_loading_description": "Loading agent description...",
"com_agents_error_loading": "Error loading agents",
"com_agents_error_searching": "Error searching agents",
"com_agents_error_title": "Something went wrong",
"com_agents_error_generic": "We encountered an issue while loading the content.",
"com_agents_error_suggestion_generic": "Please try refreshing the page or try again later.",
"com_agents_error_network_title": "Connection Problem",
"com_agents_error_network_message": "Unable to connect to the server.",
"com_agents_error_network_suggestion": "Check your internet connection and try again.",
"com_agents_error_not_found_title": "Not Found",
"com_agents_error_not_found_message": "The requested content could not be found.",
"com_agents_error_not_found_suggestion": "Try browsing other options or go back to the marketplace.",
"com_agents_error_invalid_request": "Invalid Request",
"com_agents_error_bad_request_message": "The request could not be processed.",
"com_agents_error_bad_request_suggestion": "Please check your input and try again.",
"com_agents_error_server_title": "Server Error",
"com_agents_error_server_message": "The server is temporarily unavailable.",
"com_agents_error_server_suggestion": "Please try again in a few moments.",
"com_agents_error_search_title": "Search Error",
"com_agents_error_category_title": "Category Error",
"com_agents_search_no_results": "No agents found for \"{{query}}\"",
"com_agents_category_empty": "No agents found in the {{category}} category",
"com_agents_error_retry": "Try Again",
"com_agents_category_tabs_label": "Agent Categories",
"com_agents_category_tab_label": "{{category}} category, {{position}} of {{total}}",
"com_agents_search_instructions": "Type to search agents by name or description",
"com_agents_grid_announcement": "Showing {{count}} agents in {{category}} category",
"com_agents_load_more_label": "Load more agents from {{category}} category",
"com_agents_agent_card_label": "{{name}} agent. {{description}}",
"com_agents_empty_state_heading": "No agents found",
"com_agents_search_empty_heading": "No search results",
"com_agents_no_description": "No description available",
"com_agents_none_in_category": "No agents found in this category",
"com_agents_no_results": "No agents found. Try another search term.",
"com_agents_results_for": "Results for '{{query}}'",
"com_nav_agents_marketplace": "Agent Marketplace",
"com_agents_marketplace_subtitle": "Discover and use powerful AI agents to enhance your workflows and productivity"
}

View file

@ -18,6 +18,7 @@ import ShareRoute from './ShareRoute';
import ChatRoute from './ChatRoute';
import Search from './Search';
import Root from './Root';
import AgentMarketplace from '~/components/SidePanel/Agents/AgentMarketplace';
const AuthLayout = () => (
<AuthContextProvider>
@ -105,6 +106,14 @@ export const router = createBrowserRouter([
path: 'search',
element: <Search />,
},
{
path: 'agents',
element: <AgentMarketplace />,
},
{
path: 'agents/:category',
element: <AgentMarketplace />,
},
],
},
],

View file

@ -0,0 +1,178 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { getAgentAvatarUrl, renderAgentAvatar, getContactDisplayName } from '../agents';
import type t from 'librechat-data-provider';
// Mock the Bot icon from lucide-react
jest.mock('lucide-react', () => ({
Bot: ({ className, strokeWidth, ...props }: any) => (
<svg data-testid="bot-icon" className={className} data-stroke-width={strokeWidth} {...props}>
<title>{/* eslint-disable-line i18next/no-literal-string */}Bot Icon</title>
</svg>
),
}));
describe('Agent Utilities', () => {
describe('getAgentAvatarUrl', () => {
it('should return null for null agent', () => {
expect(getAgentAvatarUrl(null)).toBeNull();
});
it('should return null for undefined agent', () => {
expect(getAgentAvatarUrl(undefined)).toBeNull();
});
it('should return null for agent without avatar', () => {
const agent = { id: '1', name: 'Test Agent' } as t.Agent;
expect(getAgentAvatarUrl(agent)).toBeNull();
});
it('should return string avatar directly', () => {
const agent = {
id: '1',
name: 'Test Agent',
avatar: '/path/to/avatar.png',
} as t.Agent;
expect(getAgentAvatarUrl(agent)).toBe('/path/to/avatar.png');
});
it('should extract filepath from object avatar', () => {
const agent = {
id: '1',
name: 'Test Agent',
avatar: { filepath: '/path/to/object-avatar.png' },
} as t.Agent;
expect(getAgentAvatarUrl(agent)).toBe('/path/to/object-avatar.png');
});
it('should return null for object avatar without filepath', () => {
const agent = {
id: '1',
name: 'Test Agent',
avatar: { someOtherProperty: 'value' },
} as any;
expect(getAgentAvatarUrl(agent)).toBeNull();
});
});
describe('renderAgentAvatar', () => {
it('should render image when avatar URL exists', () => {
const agent = {
id: '1',
name: 'Test Agent',
avatar: '/test-avatar.png',
} as t.Agent;
render(<div>{renderAgentAvatar(agent)}</div>);
const img = screen.getByAltText('Test Agent avatar');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', '/test-avatar.png');
expect(img).toHaveClass('rounded-full', 'object-cover', 'shadow-lg');
});
it('should render Bot icon fallback when no avatar', () => {
const agent = {
id: '1',
name: 'Test Agent',
} as t.Agent;
render(<div>{renderAgentAvatar(agent)}</div>);
const botIcon = screen.getByTestId('bot-icon');
expect(botIcon).toBeInTheDocument();
expect(botIcon).toHaveAttribute('data-stroke-width', '1.5');
});
it('should apply different size classes', () => {
const agent = {
id: '1',
name: 'Test Agent',
avatar: '/test-avatar.png',
} as t.Agent;
const { rerender } = render(<div>{renderAgentAvatar(agent, { size: 'sm' })}</div>);
expect(screen.getByAltText('Test Agent avatar')).toHaveClass('h-12', 'w-12');
rerender(<div>{renderAgentAvatar(agent, { size: 'lg' })}</div>);
expect(screen.getByAltText('Test Agent avatar')).toHaveClass('h-20', 'w-20');
rerender(<div>{renderAgentAvatar(agent, { size: 'xl' })}</div>);
expect(screen.getByAltText('Test Agent avatar')).toHaveClass('h-24', 'w-24');
});
it('should apply custom className', () => {
const agent = {
id: '1',
name: 'Test Agent',
avatar: '/test-avatar.png',
} as t.Agent;
render(<div>{renderAgentAvatar(agent, { className: 'custom-class' })}</div>);
const container = screen.getByAltText('Test Agent avatar').parentElement;
expect(container).toHaveClass('custom-class');
});
it('should handle showBorder option', () => {
const agent = {
id: '1',
name: 'Test Agent',
avatar: '/test-avatar.png',
} as t.Agent;
const { rerender } = render(<div>{renderAgentAvatar(agent, { showBorder: true })}</div>);
expect(screen.getByAltText('Test Agent avatar')).toHaveClass('border-2');
rerender(<div>{renderAgentAvatar(agent, { showBorder: false })}</div>);
expect(screen.getByAltText('Test Agent avatar')).not.toHaveClass('border-2');
});
});
describe('getContactDisplayName', () => {
it('should return null for null agent', () => {
expect(getContactDisplayName(null)).toBeNull();
});
it('should return null for undefined agent', () => {
expect(getContactDisplayName(undefined)).toBeNull();
});
it('should prioritize support_contact name', () => {
const agent = {
id: '1',
support_contact: { name: 'Support Team', email: 'support@example.com' },
authorName: 'John Doe',
} as any;
expect(getContactDisplayName(agent)).toBe('Support Team');
});
it('should use authorName when support_contact name is missing', () => {
const agent = {
id: '1',
support_contact: { email: 'support@example.com' },
authorName: 'John Doe',
} as any;
expect(getContactDisplayName(agent)).toBe('John Doe');
});
it('should use support_contact email when both name and authorName are missing', () => {
const agent = {
id: '1',
support_contact: { email: 'support@example.com' },
} as any;
expect(getContactDisplayName(agent)).toBe('support@example.com');
});
it('should return null when no contact info is available', () => {
const agent = {
id: '1',
name: 'Test Agent',
} as any;
expect(getContactDisplayName(agent)).toBeNull();
});
});
// Tests for hardcoded category functions removed - now using database-driven categories
});

109
client/src/utils/agents.tsx Normal file
View file

@ -0,0 +1,109 @@
import React from 'react';
import { Bot } from 'lucide-react';
import type t from 'librechat-data-provider';
/**
* Extracts the avatar URL from an agent's avatar property
* Handles both string and object formats
*/
export const getAgentAvatarUrl = (agent: t.Agent | null | undefined): string | null => {
if (!agent?.avatar) {
return null;
}
if (typeof agent.avatar === 'string') {
return agent.avatar;
}
if (agent.avatar && typeof agent.avatar === 'object' && 'filepath' in agent.avatar) {
return agent.avatar.filepath;
}
return null;
};
/**
* Renders an agent avatar with fallback to Bot icon
* Consistent across all agent displays
*/
export const renderAgentAvatar = (
agent: t.Agent | null | undefined,
options: {
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
showBorder?: boolean;
} = {},
): React.ReactElement => {
const { size = 'md', className = '', showBorder = true } = options;
const avatarUrl = getAgentAvatarUrl(agent);
// Size mappings for responsive design
const sizeClasses = {
sm: 'h-12 w-12 sm:h-14 sm:w-14',
md: 'h-16 w-16 sm:h-20 sm:w-20 md:h-24 md:w-24',
lg: 'h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28',
xl: 'h-24 w-24',
};
const iconSizeClasses = {
sm: 'h-6 w-6 sm:h-7 sm:w-7',
md: 'h-6 w-6 sm:h-8 sm:w-8 md:h-10 md:w-10',
lg: 'h-8 w-8 sm:h-10 sm:w-10 md:h-12 md:w-12',
xl: 'h-10 w-10',
};
const placeholderSizeClasses = {
sm: 'h-10 w-10 sm:h-12 sm:w-12',
md: 'h-12 w-12 sm:h-16 sm:w-16 md:h-20 md:w-20',
lg: 'h-16 w-16 sm:h-20 sm:w-20 md:h-24 md:w-24',
xl: 'h-20 w-20',
};
const borderClasses = showBorder ? 'border-2 border-white dark:border-gray-800' : '';
if (avatarUrl) {
return (
<div className={`flex items-center justify-center ${sizeClasses[size]} ${className}`}>
<img
src={avatarUrl}
alt={`${agent?.name || 'Agent'} avatar`}
className={`${sizeClasses[size]} rounded-full object-cover shadow-lg ${borderClasses}`}
loading="lazy"
/>
</div>
);
}
// Fallback placeholder with Bot icon
return (
<div className={`relative flex items-center justify-center ${sizeClasses[size]} ${className}`}>
{/* Subtle minimalistic placeholder */}
<div className="absolute inset-0 rounded-full border border-gray-300 bg-gray-200 dark:border-gray-600 dark:bg-gray-700"></div>
<div
className={`relative flex items-center justify-center rounded-full bg-gray-300 dark:bg-gray-600 ${placeholderSizeClasses[size]}`}
>
<Bot
className={`text-gray-500 dark:text-gray-400 ${iconSizeClasses[size]}`}
strokeWidth={1.5}
/>
</div>
</div>
);
};
/**
* Gets the display name for a contact (prioritizes name over email)
*/
export const getContactDisplayName = (agent: t.Agent | null | undefined): string | null => {
if (!agent) return null;
const supportName = (agent as any).support_contact?.name;
const supportEmail = (agent as any).support_contact?.email;
const authorName = (agent as any).authorName;
return supportName || authorName || supportEmail || null;
};
// All hardcoded category constants removed - now using database-driven categories

View file

@ -21,6 +21,21 @@ beforeEach(() => {
jest.clearAllMocks();
});
// Mock window.matchMedia for tests
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
jest.mock('react-i18next', () => {
const actual = jest.requireActual('react-i18next');
return {

106
config/seed-categories.js Normal file
View file

@ -0,0 +1,106 @@
const connectDb = require('../api/lib/db/connectDb');
const AgentCategory = require('../api/models/AgentCategory');
// Define category constants directly since the constants file was removed
const CATEGORY_VALUES = {
GENERAL: 'general',
HR: 'hr',
RD: 'rd',
FINANCE: 'finance',
IT: 'it',
SALES: 'sales',
AFTERSALES: 'aftersales',
};
const CATEGORY_DESCRIPTIONS = {
general: 'General purpose agents for common tasks and inquiries',
hr: 'Agents specialized in HR processes, policies, and employee support',
rd: 'Agents focused on R&D processes, innovation, and technical research',
finance: 'Agents specialized in financial analysis, budgeting, and accounting',
it: 'Agents for IT support, technical troubleshooting, and system administration',
sales: 'Agents focused on sales processes, customer relations, and marketing',
aftersales: 'Agents specialized in post-sale support, maintenance, and customer service',
};
/**
* Seed agent categories from existing constants into MongoDB
* This migration creates the initial category data in the database
*/
async function seedCategories() {
try {
await connectDb();
console.log('Connected to database');
// Prepare category data from existing constants
const categoryData = [
{
value: CATEGORY_VALUES.GENERAL,
label: 'General',
description: CATEGORY_DESCRIPTIONS.general,
order: 0,
},
{
value: CATEGORY_VALUES.HR,
label: 'Human Resources',
description: CATEGORY_DESCRIPTIONS.hr,
order: 1,
},
{
value: CATEGORY_VALUES.RD,
label: 'Research & Development',
description: CATEGORY_DESCRIPTIONS.rd,
order: 2,
},
{
value: CATEGORY_VALUES.FINANCE,
label: 'Finance',
description: CATEGORY_DESCRIPTIONS.finance,
order: 3,
},
{
value: CATEGORY_VALUES.IT,
label: 'Information Technology',
description: CATEGORY_DESCRIPTIONS.it,
order: 4,
},
{
value: CATEGORY_VALUES.SALES,
label: 'Sales & Marketing',
description: CATEGORY_DESCRIPTIONS.sales,
order: 5,
},
{
value: CATEGORY_VALUES.AFTERSALES,
label: 'After Sales',
description: CATEGORY_DESCRIPTIONS.aftersales,
order: 6,
},
];
console.log('Seeding categories...');
const result = await AgentCategory.seedCategories(categoryData);
console.log(`Successfully seeded ${result.upsertedCount} new categories`);
console.log(`Modified ${result.modifiedCount} existing categories`);
// Verify the seeded data
const categories = await AgentCategory.getActiveCategories();
console.log('Active categories in database:');
categories.forEach((cat) => {
console.log(` - ${cat.value}: ${cat.label} (order: ${cat.order})`);
});
console.log('Category seeding completed successfully');
process.exit(0);
} catch (error) {
console.error('Error seeding categories:', error);
process.exit(1);
}
}
// Run if called directly
if (require.main === module) {
seedCategories();
}
module.exports = seedCategories;

View file

@ -34,6 +34,7 @@
"delete-user": "node config/delete-user.js",
"update-banner": "node config/update-banner.js",
"delete-banner": "node config/delete-banner.js",
"seed-categories": "node config/seed-categories.js",
"backend": "cross-env NODE_ENV=production node api/server/index.js",
"backend:dev": "cross-env NODE_ENV=development npx nodemon api/server/index.js",
"backend:stop": "node config/stop-backend.js",

View file

@ -436,6 +436,80 @@ export const revertAgentVersion = ({
version_index: number;
}): Promise<a.Agent> => request.post(endpoints.revertAgentVersion(agent_id), { version_index });
/* Marketplace */
/**
* Get agent categories with counts for marketplace tabs
*/
export const getAgentCategories = (): Promise<t.TMarketplaceCategory[]> => {
return request.get(endpoints.agents({ path: 'marketplace/categories' }));
};
/**
* Get promoted/top picks agents with pagination
*/
export const getPromotedAgents = (params: {
page?: number;
limit?: number;
showAll?: string; // Add showAll parameter to get all shared agents instead of just promoted
}): Promise<a.AgentListResponse> => {
return request.get(
endpoints.agents({
path: 'marketplace/promoted',
options: params,
}),
);
};
/**
* Get all agents with pagination (for "all" category)
*/
export const getAllAgents = (params: {
page?: number;
limit?: number;
}): Promise<a.AgentListResponse> => {
return request.get(
endpoints.agents({
path: 'marketplace/all',
options: params,
}),
);
};
/**
* Get agents by category with pagination
*/
export const getAgentsByCategory = (params: {
category: string;
page?: number;
limit?: number;
}): Promise<a.AgentListResponse> => {
const { category, ...options } = params;
return request.get(
endpoints.agents({
path: `marketplace/category/${category}`,
options,
}),
);
};
/**
* Search agents in marketplace
*/
export const searchAgents = (params: {
q: string;
category?: string;
page?: number;
limit?: number;
}): Promise<a.AgentListResponse> => {
return request.get(
endpoints.agents({
path: 'marketplace/search',
options: params,
}),
);
};
/* Tools */
export const getAvailableAgentTools = (): Promise<s.TPlugin[]> => {

View file

@ -164,6 +164,11 @@ export const defaultAgentFormValues = {
[Tools.execute_code]: false,
[Tools.file_search]: false,
[Tools.web_search]: false,
category: 'general',
support_contact: {
name: '',
email: '',
},
};
export const ImageVisionTool: FunctionTool = {

View file

@ -154,6 +154,11 @@ export type TCategory = {
label: string;
};
export type TMarketplaceCategory = TCategory & {
count: number;
description?: string;
};
export type TError = {
message: string;
code?: number | string;

View file

@ -1,5 +1,10 @@
import { Document, Types } from 'mongoose';
export interface ISupportContact {
name?: string;
email?: string;
}
export interface IAgent extends Omit<Document, 'model'> {
id: string;
name?: string;
@ -28,4 +33,6 @@ export interface IAgent extends Omit<Document, 'model'> {
tool_resources?: unknown;
projectIds?: Types.ObjectId[];
versions?: Omit<IAgent, 'versions'>[];
category: string;
support_contact?: ISupportContact;
}