feat: Refactor FavoritesController validation, streamline ModelSelector component, and enhance EndpointModelItem with selection state

This commit is contained in:
Marco Beretta 2025-11-30 22:38:36 +01:00
parent 5292c4ea73
commit e48813acfe
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
7 changed files with 65 additions and 70 deletions

View file

@ -9,7 +9,6 @@ const updateFavoritesController = async (req, res) => {
return res.status(400).json({ message: 'Favorites data is required' }); return res.status(400).json({ message: 'Favorites data is required' });
} }
// Validate favorites structure
if (!Array.isArray(favorites)) { if (!Array.isArray(favorites)) {
return res.status(400).json({ message: 'Favorites must be an array' }); return res.status(400).json({ message: 'Favorites must be an array' });
} }

View file

@ -13,7 +13,7 @@ interface EndpointModelItemProps {
isSelected: boolean; isSelected: boolean;
} }
export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps) { export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointModelItemProps) {
const { handleSelectModel } = useModelSelectorContext(); const { handleSelectModel } = useModelSelectorContext();
const { isFavoriteModel, toggleFavoriteModel, isFavoriteAgent, toggleFavoriteAgent } = const { isFavoriteModel, toggleFavoriteModel, isFavoriteAgent, toggleFavoriteAgent } =
useFavorites(); useFavorites();
@ -43,34 +43,42 @@ export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps)
const handleFavoriteClick = (e: React.MouseEvent) => { const handleFavoriteClick = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (modelId) { if (!modelId) {
if (isAgent) { return;
toggleFavoriteAgent(modelId); }
} else {
toggleFavoriteModel({ model: modelId, endpoint: endpoint.value }); if (isAgent) {
} toggleFavoriteAgent(modelId);
} else {
toggleFavoriteModel({ model: modelId, endpoint: endpoint.value });
} }
}; };
const renderAvatar = () => { const renderAvatar = () => {
if (avatarUrl) { const isAgentOrAssistant =
return ( isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value);
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full"> const showEndpointIcon = isAgentOrAssistant && endpoint.icon;
<img src={avatarUrl} alt={modelName ?? ''} className="h-full w-full object-cover" />
</div> const getContent = () => {
); if (avatarUrl) {
return <img src={avatarUrl} alt={modelName ?? ''} className="h-full w-full object-cover" />;
}
if (showEndpointIcon) {
return endpoint.icon;
}
return null;
};
const content = getContent();
if (!content) {
return null;
} }
if (
(isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) && return (
endpoint.icon <div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full">
) { {content}
return ( </div>
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full"> );
{endpoint.icon}
</div>
);
}
return null;
}; };
return ( return (
@ -97,6 +105,25 @@ export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps)
<Pin className="h-4 w-4 text-text-secondary" /> <Pin className="h-4 w-4 text-text-secondary" />
)} )}
</button> </button>
{isSelected && (
<div className="flex-shrink-0 self-center">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="block"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
fill="currentColor"
/>
</svg>
</div>
)}
</MenuItem> </MenuItem>
); );
} }

View file

@ -1,12 +1,12 @@
import { useMemo, memo, type FC, useCallback, useEffect, useRef } from 'react'; import { useMemo, memo, type FC, useCallback, useEffect, useRef } from 'react';
import throttle from 'lodash/throttle'; import throttle from 'lodash/throttle';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import { TConversation } from 'librechat-data-provider';
import { Spinner, useMediaQuery } from '@librechat/client'; import { Spinner, useMediaQuery } from '@librechat/client';
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized'; import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized';
import { TConversation } from 'librechat-data-provider'; import FavoritesList from '~/components/Nav/Favorites/FavoritesList';
import { useLocalize, TranslationKeys, useFavorites } from '~/hooks'; import { useLocalize, TranslationKeys, useFavorites } from '~/hooks';
import { groupConversationsByDate, cn } from '~/utils'; import { groupConversationsByDate, cn } from '~/utils';
import FavoritesList from '~/components/Nav/Favorites/FavoritesList';
import Convo from './Convo'; import Convo from './Convo';
interface ConversationsProps { interface ConversationsProps {
@ -17,7 +17,6 @@ interface ConversationsProps {
loadMoreConversations: () => void; loadMoreConversations: () => void;
isLoading: boolean; isLoading: boolean;
isSearchLoading: boolean; isSearchLoading: boolean;
scrollElement?: HTMLElement | null;
isChatsExpanded: boolean; isChatsExpanded: boolean;
setIsChatsExpanded: (expanded: boolean) => void; setIsChatsExpanded: (expanded: boolean) => void;
} }
@ -85,7 +84,6 @@ const Conversations: FC<ConversationsProps> = ({
loadMoreConversations, loadMoreConversations,
isLoading, isLoading,
isSearchLoading, isSearchLoading,
scrollElement,
isChatsExpanded, isChatsExpanded,
setIsChatsExpanded, setIsChatsExpanded,
}) => { }) => {
@ -168,7 +166,7 @@ const Conversations: FC<ConversationsProps> = ({
} }
}, [cache, containerRef]); }, [cache, containerRef]);
// Clear cache when favorites change - use requestAnimationFrame for smoother updates // Clear cache when favorites change
useEffect(() => { useEffect(() => {
const frameId = requestAnimationFrame(() => { const frameId = requestAnimationFrame(() => {
clearFavoritesCache(); clearFavoritesCache();

View file

@ -1,9 +1,9 @@
import React, { useCallback, useContext } from 'react'; import React, { useCallback } from 'react';
import { LayoutGrid } from 'lucide-react'; import { LayoutGrid } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { TooltipAnchor, Button } from '@librechat/client'; import { TooltipAnchor, Button } from '@librechat/client';
import { useLocalize, useHasAccess, AuthContext } from '~/hooks'; import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { useLocalize, useHasAccess } from '~/hooks';
interface AgentMarketplaceButtonProps { interface AgentMarketplaceButtonProps {
isSmallScreen?: boolean; isSmallScreen?: boolean;
@ -16,7 +16,6 @@ export default function AgentMarketplaceButton({
}: AgentMarketplaceButtonProps) { }: AgentMarketplaceButtonProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const localize = useLocalize(); const localize = useLocalize();
const authContext = useContext(AuthContext);
const hasAccessToAgents = useHasAccess({ const hasAccessToAgents = useHasAccess({
permissionType: PermissionTypes.AGENTS, permissionType: PermissionTypes.AGENTS,
@ -35,13 +34,7 @@ export default function AgentMarketplaceButton({
} }
}, [navigate, isSmallScreen, toggleNav]); }, [navigate, isSmallScreen, toggleNav]);
// Check if auth is ready (avoid race conditions) const showAgentMarketplace = hasAccessToAgents && hasAccessToMarketplace;
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) { if (!showAgentMarketplace) {
return null; return null;

View file

@ -1,4 +1,4 @@
import React, { useRef, useCallback, useMemo, useContext, useEffect } from 'react'; import React, { useRef, useCallback, useMemo, useEffect } from 'react';
import { LayoutGrid } from 'lucide-react'; import { LayoutGrid } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useDrag, useDrop } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd';
@ -8,11 +8,10 @@ import { QueryKeys, dataService, PermissionTypes, Permissions } from 'librechat-
import { useQueries, useQueryClient } from '@tanstack/react-query'; import { useQueries, useQueryClient } from '@tanstack/react-query';
import type { InfiniteData } from '@tanstack/react-query'; import type { InfiniteData } from '@tanstack/react-query';
import type t from 'librechat-data-provider'; import type t from 'librechat-data-provider';
import { useFavorites, useLocalize, useHasAccess, AuthContext } from '~/hooks'; import { useFavorites, useLocalize, useHasAccess } from '~/hooks';
import FavoriteItem from './FavoriteItem'; import FavoriteItem from './FavoriteItem';
import store from '~/store'; import store from '~/store';
/** Skeleton placeholder for a favorite item while loading */
const FavoriteItemSkeleton = () => ( const FavoriteItemSkeleton = () => (
<div className="flex w-full items-center rounded-lg px-3 py-2"> <div className="flex w-full items-center rounded-lg px-3 py-2">
<Skeleton className="mr-2 h-5 w-5 rounded-full" /> <Skeleton className="mr-2 h-5 w-5 rounded-full" />
@ -20,7 +19,6 @@ const FavoriteItemSkeleton = () => (
</div> </div>
); );
/** Skeleton placeholder for the Agent Marketplace button while loading */
const MarketplaceSkeleton = () => ( const MarketplaceSkeleton = () => (
<div className="flex w-full items-center rounded-lg px-3 py-2"> <div className="flex w-full items-center rounded-lg px-3 py-2">
<Skeleton className="mr-2 h-5 w-5" /> <Skeleton className="mr-2 h-5 w-5" />
@ -121,7 +119,6 @@ export default function FavoritesList({
const navigate = useNavigate(); const navigate = useNavigate();
const localize = useLocalize(); const localize = useLocalize();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const authContext = useContext(AuthContext);
const search = useRecoilValue(store.search); const search = useRecoilValue(store.search);
const { favorites, reorderFavorites, isLoading: isFavoritesLoading } = useFavorites(); const { favorites, reorderFavorites, isLoading: isFavoritesLoading } = useFavorites();
@ -135,13 +132,8 @@ export default function FavoritesList({
permission: Permissions.USE, permission: Permissions.USE,
}); });
// Check if auth is ready (avoid race conditions) // Show agent marketplace when marketplace permission is enabled, and user has access to agents
const authReady = const showAgentMarketplace = hasAccessToAgents && hasAccessToMarketplace;
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 handleAgentMarketplace = useCallback(() => { const handleAgentMarketplace = useCallback(() => {
navigate('/agents'); navigate('/agents');
@ -160,10 +152,8 @@ export default function FavoritesList({
})), })),
}); });
// Check if any agent queries are still loading (not yet fetched)
const isAgentsLoading = agentIds.length > 0 && agentQueries.some((q) => q.isLoading); const isAgentsLoading = agentIds.length > 0 && agentQueries.some((q) => q.isLoading);
// Notify parent when agents finish loading (height might change)
useEffect(() => { useEffect(() => {
if (!isAgentsLoading && onHeightChange) { if (!isAgentsLoading && onHeightChange) {
onHeightChange(); onHeightChange();
@ -226,18 +216,14 @@ export default function FavoritesList({
draggedFavoritesRef.current = favorites; draggedFavoritesRef.current = favorites;
}, [favorites]); }, [favorites]);
// Hide favorites when search is active
if (search.query) { if (search.query) {
return null; return null;
} }
// If no favorites and no marketplace to show, and not loading, return null
if (!isFavoritesLoading && favorites.length === 0 && !showAgentMarketplace) { if (!isFavoritesLoading && favorites.length === 0 && !showAgentMarketplace) {
return null; return null;
} }
// While favorites are initially loading, show a minimal placeholder
// This prevents the "null to content" jump
if (isFavoritesLoading) { if (isFavoritesLoading) {
return ( return (
<div className="mb-2 flex flex-col pb-2"> <div className="mb-2 flex flex-col pb-2">

View file

@ -25,16 +25,9 @@ const AccountSettings = lazy(() => import('./AccountSettings'));
const NAV_WIDTH_DESKTOP = '260px'; const NAV_WIDTH_DESKTOP = '260px';
const NAV_WIDTH_MOBILE = '320px'; const NAV_WIDTH_MOBILE = '320px';
/** Skeleton placeholder for SearchBar while loading */ const SearchBarSkeleton = memo(() => (
const SearchBarSkeleton = memo(({ isSmallScreen }: { isSmallScreen: boolean }) => ( <div className={cn('flex h-10 items-center py-2')}>
<div <Skeleton className="h-10 w-full rounded-lg" />
className={cn(
'mt-1 flex items-center gap-3 rounded-lg px-3 py-2',
isSmallScreen ? 'mb-2 h-14 rounded-xl' : 'mb-1 h-10',
)}
>
<Skeleton className="h-4 w-4 rounded" />
<Skeleton className="h-4 flex-1" />
</div> </div>
)); ));

View file

@ -114,7 +114,6 @@ export const renderAgentAvatar = (
); );
} }
// Fallback placeholder with Agent (Feather) icon - consistent with MessageEndpointIcon
return ( return (
<div className={`relative flex items-center justify-center ${sizeClasses[size]} ${className}`}> <div className={`relative flex items-center justify-center ${sizeClasses[size]} ${className}`}>
<Feather className={`text-text-primary ${iconSizeClasses[size]}`} strokeWidth={1.5} /> <Feather className={`text-text-primary ${iconSizeClasses[size]}`} strokeWidth={1.5} />