diff --git a/api/server/controllers/FavoritesController.js b/api/server/controllers/FavoritesController.js index e3a9f77ae3..186dd810bf 100644 --- a/api/server/controllers/FavoritesController.js +++ b/api/server/controllers/FavoritesController.js @@ -17,7 +17,11 @@ const updateFavoritesController = async (req, res) => { } if (favorites.length > MAX_FAVORITES) { - return res.status(400).json({ message: `Maximum ${MAX_FAVORITES} favorites allowed` }); + return res.status(400).json({ + code: 'MAX_FAVORITES_EXCEEDED', + message: `Maximum ${MAX_FAVORITES} favorites allowed`, + limit: MAX_FAVORITES, + }); } for (const fav of favorites) { diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index 069d2c7b58..604c702a33 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -1,11 +1,11 @@ -import { useMemo, memo, type FC, useCallback, useEffect, useRef, useContext } from 'react'; +import { useMemo, memo, type FC, useCallback, useEffect, useRef } from 'react'; import throttle from 'lodash/throttle'; import { ChevronRight } from 'lucide-react'; import { useRecoilValue } from 'recoil'; import { Spinner, useMediaQuery } from '@librechat/client'; import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized'; -import { TConversation, PermissionTypes, Permissions } from 'librechat-data-provider'; -import { useLocalize, TranslationKeys, useFavorites, useHasAccess, AuthContext } from '~/hooks'; +import { TConversation } from 'librechat-data-provider'; +import { useLocalize, TranslationKeys, useFavorites, useShowMarketplace } from '~/hooks'; import FavoritesList from '~/components/Nav/Favorites/FavoritesList'; import { groupConversationsByDate, cn } from '~/utils'; import Convo from './Convo'; @@ -90,29 +90,11 @@ const Conversations: FC = ({ setIsChatsExpanded, }) => { const localize = useLocalize(); - const authContext = useContext(AuthContext); const search = useRecoilValue(store.search); const { favorites, isLoading: isFavoritesLoading } = useFavorites(); const isSmallScreen = useMediaQuery('(max-width: 768px)'); const convoHeight = isSmallScreen ? 44 : 34; - - const hasAccessToAgents = useHasAccess({ - permissionType: PermissionTypes.AGENTS, - permission: Permissions.USE, - }); - - const hasAccessToMarketplace = useHasAccess({ - permissionType: PermissionTypes.MARKETPLACE, - permission: Permissions.USE, - }); - - // Check if auth is ready (avoid race conditions) - const authReady = - authContext?.isAuthenticated !== undefined && - (authContext?.isAuthenticated === false || authContext?.user !== undefined); - - // Show agent marketplace when marketplace permission is enabled, auth is ready, and user has access to agents - const showAgentMarketplace = authReady && hasAccessToAgents && hasAccessToMarketplace; + const showAgentMarketplace = useShowMarketplace(); // Determine if FavoritesList will render content const shouldShowFavorites = diff --git a/client/src/components/Nav/AgentMarketplaceButton.tsx b/client/src/components/Nav/AgentMarketplaceButton.tsx index 719534b71d..d36d082ca0 100644 --- a/client/src/components/Nav/AgentMarketplaceButton.tsx +++ b/client/src/components/Nav/AgentMarketplaceButton.tsx @@ -1,9 +1,8 @@ -import React, { useCallback, useContext } from 'react'; +import React, { useCallback } from 'react'; import { LayoutGrid } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { TooltipAnchor, Button } from '@librechat/client'; -import { PermissionTypes, Permissions } from 'librechat-data-provider'; -import { useLocalize, useHasAccess, AuthContext } from '~/hooks'; +import { useLocalize, useShowMarketplace } from '~/hooks'; interface AgentMarketplaceButtonProps { isSmallScreen?: boolean; @@ -16,17 +15,7 @@ export default function AgentMarketplaceButton({ }: AgentMarketplaceButtonProps) { const navigate = useNavigate(); const localize = useLocalize(); - const authContext = useContext(AuthContext); - - const hasAccessToAgents = useHasAccess({ - permissionType: PermissionTypes.AGENTS, - permission: Permissions.USE, - }); - - const hasAccessToMarketplace = useHasAccess({ - permissionType: PermissionTypes.MARKETPLACE, - permission: Permissions.USE, - }); + const showAgentMarketplace = useShowMarketplace(); const handleAgentMarketplace = useCallback(() => { navigate('/agents'); @@ -35,14 +24,6 @@ export default function AgentMarketplaceButton({ } }, [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 marketplace permission is enabled, auth is ready, and user has access to agents - const showAgentMarketplace = authReady && hasAccessToAgents && hasAccessToMarketplace; - if (!showAgentMarketplace) { return null; } diff --git a/client/src/components/Nav/Favorites/FavoritesList.tsx b/client/src/components/Nav/Favorites/FavoritesList.tsx index b0aee7b7e7..1aa71cbbaa 100644 --- a/client/src/components/Nav/Favorites/FavoritesList.tsx +++ b/client/src/components/Nav/Favorites/FavoritesList.tsx @@ -1,14 +1,14 @@ -import React, { useRef, useCallback, useMemo, useEffect, useContext } from 'react'; +import React, { useRef, useCallback, useMemo, useEffect } from 'react'; import { useRecoilValue } from 'recoil'; import { LayoutGrid } from 'lucide-react'; import { useDrag, useDrop } from 'react-dnd'; import { Skeleton } from '@librechat/client'; import { useNavigate } from 'react-router-dom'; +import { QueryKeys, dataService } from 'librechat-data-provider'; import { useQueries, useQueryClient } from '@tanstack/react-query'; -import { QueryKeys, dataService, PermissionTypes, Permissions } from 'librechat-data-provider'; import type { InfiniteData } from '@tanstack/react-query'; import type t from 'librechat-data-provider'; -import { useFavorites, useLocalize, useHasAccess, AuthContext } from '~/hooks'; +import { useFavorites, useLocalize, useShowMarketplace } from '~/hooks'; import FavoriteItem from './FavoriteItem'; import store from '~/store'; @@ -119,27 +119,9 @@ export default function FavoritesList({ const navigate = useNavigate(); const localize = useLocalize(); const queryClient = useQueryClient(); - const authContext = useContext(AuthContext); const search = useRecoilValue(store.search); const { favorites, reorderFavorites, isLoading: isFavoritesLoading } = useFavorites(); - - const hasAccessToAgents = useHasAccess({ - permissionType: PermissionTypes.AGENTS, - permission: Permissions.USE, - }); - - const hasAccessToMarketplace = useHasAccess({ - permissionType: PermissionTypes.MARKETPLACE, - permission: Permissions.USE, - }); - - // Check if auth is ready (avoid race conditions) - const authReady = - authContext?.isAuthenticated !== undefined && - (authContext?.isAuthenticated === false || authContext?.user !== undefined); - - // Show agent marketplace when marketplace permission is enabled, auth is ready, and user has access to agents - const showAgentMarketplace = authReady && hasAccessToAgents && hasAccessToMarketplace; + const showAgentMarketplace = useShowMarketplace(); const handleAgentMarketplace = useCallback(() => { navigate('/agents'); diff --git a/client/src/hooks/Nav/index.ts b/client/src/hooks/Nav/index.ts index 16f4b43489..c7b0ae7b7b 100644 --- a/client/src/hooks/Nav/index.ts +++ b/client/src/hooks/Nav/index.ts @@ -1,2 +1,3 @@ export { default as useNavScrolling } from './useNavScrolling'; +export { default as useShowMarketplace } from './useShowMarketplace'; export * from './useNavHelpers'; diff --git a/client/src/hooks/Nav/useShowMarketplace.ts b/client/src/hooks/Nav/useShowMarketplace.ts new file mode 100644 index 0000000000..0be559e88d --- /dev/null +++ b/client/src/hooks/Nav/useShowMarketplace.ts @@ -0,0 +1,37 @@ +import { useContext, useMemo } from 'react'; +import { PermissionTypes, Permissions } from 'librechat-data-provider'; +import { useHasAccess, AuthContext } from '~/hooks'; + +/** + * Hook to determine if the Agent Marketplace should be shown. + * Consolidates the logic for checking: + * - Auth readiness (avoid race conditions) + * - Access to Agents permission + * - Access to Marketplace permission + * + * @returns Whether the Agent Marketplace should be displayed + */ +export default function useShowMarketplace(): boolean { + const authContext = useContext(AuthContext); + + const hasAccessToAgents = useHasAccess({ + permissionType: PermissionTypes.AGENTS, + permission: Permissions.USE, + }); + + const hasAccessToMarketplace = useHasAccess({ + permissionType: PermissionTypes.MARKETPLACE, + permission: Permissions.USE, + }); + + // Check if auth is ready (avoid race conditions) + const authReady = useMemo( + () => + authContext?.isAuthenticated !== undefined && + (authContext?.isAuthenticated === false || authContext?.user !== undefined), + [authContext?.isAuthenticated, authContext?.user], + ); + + // Show agent marketplace when marketplace permission is enabled, auth is ready, and user has access to agents + return authReady && hasAccessToAgents && hasAccessToMarketplace; +} diff --git a/client/src/hooks/useFavorites.ts b/client/src/hooks/useFavorites.ts index 6fbe311689..572179ac44 100644 --- a/client/src/hooks/useFavorites.ts +++ b/client/src/hooks/useFavorites.ts @@ -7,6 +7,9 @@ import { favoritesAtom } from '~/store'; import { useLocalize } from '~/hooks'; import { logger } from '~/utils'; +/** Maximum number of favorites allowed (must match backend MAX_FAVORITES) */ +const MAX_FAVORITES = 50; + /** * Hook for managing user favorites (pinned agents and models). * @@ -61,6 +64,23 @@ export default function useFavorites() { } }, [getFavoritesQuery.data, setFavorites, updateFavoritesMutation.isLoading]); + const getErrorMessage = useCallback( + (error: unknown): string => { + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as { + response?: { data?: { code?: string; limit?: number } }; + }; + const { code, limit } = axiosError.response?.data ?? {}; + + if (code === 'MAX_FAVORITES_EXCEEDED') { + return localize('com_ui_max_favorites_reached', { 0: String(limit ?? MAX_FAVORITES) }); + } + } + return localize('com_ui_error'); + }, + [localize], + ); + const saveFavorites = useCallback( async (newFavorites: typeof favorites) => { const cleaned = cleanFavorites(newFavorites); @@ -70,7 +90,7 @@ export default function useFavorites() { await updateFavoritesMutation.mutateAsync(cleaned); } catch (error) { logger.error('Error updating favorites:', error); - showToast({ message: localize('com_ui_error'), status: 'error' }); + showToast({ message: getErrorMessage(error), status: 'error' }); // Refetch to resync state with server getFavoritesQuery.refetch(); } finally { @@ -81,7 +101,7 @@ export default function useFavorites() { }, 100); } }, - [setFavorites, updateFavoritesMutation, showToast, localize, getFavoritesQuery], + [setFavorites, updateFavoritesMutation, showToast, getErrorMessage, getFavoritesQuery], ); const addFavoriteAgent = (agentId: string) => { @@ -147,8 +167,8 @@ export default function useFavorites() { try { await updateFavoritesMutation.mutateAsync(cleaned); } catch (error) { - console.error('Error reordering favorites:', error); - showToast({ message: localize('com_ui_error'), status: 'error' }); + logger.error('Error reordering favorites:', error); + showToast({ message: getErrorMessage(error), status: 'error' }); // Refetch to resync state with server getFavoritesQuery.refetch(); } finally { @@ -158,7 +178,7 @@ export default function useFavorites() { } } }, - [setFavorites, updateFavoritesMutation, showToast, localize, getFavoritesQuery], + [setFavorites, updateFavoritesMutation, showToast, getErrorMessage, getFavoritesQuery], ); return { diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 8a7ce3bfb5..eee9192edf 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1034,6 +1034,7 @@ "com_ui_manage": "Manage", "com_ui_marketplace": "Marketplace", "com_ui_marketplace_allow_use": "Allow using Marketplace", + "com_ui_max_favorites_reached": "Maximum pinned items reached ({{0}}). Unpin an item to add more.", "com_ui_max_file_size": "PNG, JPG or JPEG (max {{0}})", "com_ui_max_tags": "Maximum number allowed is {{0}}, using latest values.", "com_ui_mcp_authenticated_success": "MCP server '{{0}}' authenticated successfully",