diff --git a/api/server/controllers/FavoritesController.js b/api/server/controllers/FavoritesController.js
index ea268fe1fb..e3a9f77ae3 100644
--- a/api/server/controllers/FavoritesController.js
+++ b/api/server/controllers/FavoritesController.js
@@ -1,5 +1,8 @@
const { updateUser, getUserById } = require('~/models');
+const MAX_FAVORITES = 50;
+const MAX_STRING_LENGTH = 256;
+
const updateFavoritesController = async (req, res) => {
try {
const { favorites } = req.body;
@@ -13,10 +16,30 @@ const updateFavoritesController = async (req, res) => {
return res.status(400).json({ message: 'Favorites must be an array' });
}
+ if (favorites.length > MAX_FAVORITES) {
+ return res.status(400).json({ message: `Maximum ${MAX_FAVORITES} favorites allowed` });
+ }
+
for (const fav of favorites) {
const hasAgent = !!fav.agentId;
const hasModel = !!(fav.model && fav.endpoint);
+ if (fav.agentId && fav.agentId.length > MAX_STRING_LENGTH) {
+ return res
+ .status(400)
+ .json({ message: `agentId exceeds maximum length of ${MAX_STRING_LENGTH}` });
+ }
+ if (fav.model && fav.model.length > MAX_STRING_LENGTH) {
+ return res
+ .status(400)
+ .json({ message: `model exceeds maximum length of ${MAX_STRING_LENGTH}` });
+ }
+ if (fav.endpoint && fav.endpoint.length > MAX_STRING_LENGTH) {
+ return res
+ .status(400)
+ .json({ message: `endpoint exceeds maximum length of ${MAX_STRING_LENGTH}` });
+ }
+
if (!hasAgent && !hasModel) {
return res.status(400).json({
message: 'Each favorite must have either agentId or model+endpoint',
diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx
index cdd88d6799..5ba4e4985a 100644
--- a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx
+++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx
@@ -3,8 +3,8 @@ import { EarthIcon, Pin, PinOff } from 'lucide-react';
import { isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import { useModelSelectorContext } from '../ModelSelectorContext';
import { CustomMenuItem as MenuItem } from '../CustomMenu';
+import { useFavorites, useLocalize } from '~/hooks';
import type { Endpoint } from '~/common';
-import { useFavorites } from '~/hooks';
import { cn } from '~/utils';
interface EndpointModelItemProps {
@@ -14,6 +14,7 @@ interface EndpointModelItemProps {
}
export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointModelItemProps) {
+ const localize = useLocalize();
const { handleSelectModel } = useModelSelectorContext();
const { isFavoriteModel, toggleFavoriteModel, isFavoriteAgent, toggleFavoriteAgent } =
useFavorites();
@@ -94,6 +95,7 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
);
} else if (item.type === 'header') {
- rendering = ;
+ // First date header index depends on whether favorites row is included
+ // With favorites: [favorites, chats-header, first-header] → index 2
+ // Without favorites: [chats-header, first-header] → index 1
+ const firstHeaderIndex = shouldShowFavorites ? 2 : 1;
+ rendering = ;
} else if (item.type === 'convo') {
rendering = (
@@ -230,7 +256,18 @@ const Conversations: FC = ({
);
},
- [cache, flattenedItems, moveToTop, toggleNav, clearFavoritesCache, isSmallScreen],
+ [
+ cache,
+ flattenedItems,
+ moveToTop,
+ toggleNav,
+ clearFavoritesCache,
+ isSmallScreen,
+ isChatsExpanded,
+ localize,
+ setIsChatsExpanded,
+ shouldShowFavorites,
+ ],
);
const getRowHeight = useCallback(
@@ -264,7 +301,7 @@ const Conversations: FC = ({
{({ width, height }) => (
}
+ ref={containerRef}
width={width}
height={height}
deferredMeasurementCache={cache}
diff --git a/client/src/components/Nav/Nav.tsx b/client/src/components/Nav/Nav.tsx
index e2423bf83d..60486f96c8 100644
--- a/client/src/components/Nav/Nav.tsx
+++ b/client/src/components/Nav/Nav.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
import { useRecoilValue } from 'recoil';
+import { List } from 'react-virtualized';
import { AnimatePresence, motion } from 'framer-motion';
import { Skeleton, useMediaQuery } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
@@ -100,7 +101,7 @@ const Nav = memo(
}, [data?.pages]);
const outerContainerRef = useRef(null);
- const conversationsRef = useRef(null);
+ const conversationsRef = useRef(null);
const { moveToTop } = useNavScrolling({
setShowLoading,
diff --git a/client/src/hooks/useFavorites.ts b/client/src/hooks/useFavorites.ts
index d3d1b560e8..3decc8ef33 100644
--- a/client/src/hooks/useFavorites.ts
+++ b/client/src/hooks/useFavorites.ts
@@ -1,7 +1,11 @@
-import { useEffect } from 'react';
+import { useEffect, useCallback } from 'react';
import { useRecoilState } from 'recoil';
-import store from '~/store';
+import { useToastContext } from '@librechat/client';
+import type { Favorite } from '~/store/favorites';
import { useGetFavoritesQuery, useUpdateFavoritesMutation } from '~/data-provider';
+import { useLocalize } from '~/hooks';
+import { logger } from '~/utils';
+import store from '~/store';
/**
* Hook for managing user favorites (pinned agents and models).
@@ -14,7 +18,24 @@ import { useGetFavoritesQuery, useUpdateFavoritesMutation } from '~/data-provide
* @returns Object containing favorites state and helper methods for
* adding, removing, toggling, reordering, and checking favorites.
*/
+
+/**
+ * Cleans favorites array to only include canonical shapes (agentId or model+endpoint).
+ */
+const cleanFavorites = (favorites: Favorite[]): Favorite[] =>
+ favorites.map((f) => {
+ if (f.agentId) {
+ return { agentId: f.agentId };
+ }
+ if (f.model && f.endpoint) {
+ return { model: f.model, endpoint: f.endpoint };
+ }
+ return f;
+ });
+
export default function useFavorites() {
+ const localize = useLocalize();
+ const { showToast } = useToastContext();
const [favorites, setFavorites] = useRecoilState(store.favorites);
const getFavoritesQuery = useGetFavoritesQuery();
const updateFavoritesMutation = useUpdateFavoritesMutation();
@@ -29,15 +50,21 @@ export default function useFavorites() {
}
}, [getFavoritesQuery.data, setFavorites]);
- const saveFavorites = (newFavorites: typeof favorites) => {
- const cleaned = newFavorites.map((f) => {
- if (f.agentId) return { agentId: f.agentId };
- if (f.model && f.endpoint) return { model: f.model, endpoint: f.endpoint };
- return f;
- });
- setFavorites(cleaned);
- updateFavoritesMutation.mutate(cleaned);
- };
+ const saveFavorites = useCallback(
+ async (newFavorites: typeof favorites) => {
+ const cleaned = cleanFavorites(newFavorites);
+ setFavorites(cleaned);
+ try {
+ await updateFavoritesMutation.mutateAsync(cleaned);
+ } catch (error) {
+ logger.error('Error updating favorites:', error);
+ showToast({ message: localize('com_ui_error'), status: 'error' });
+ // Refetch to resync state with server
+ getFavoritesQuery.refetch();
+ }
+ },
+ [setFavorites, updateFavoritesMutation, showToast, localize, getFavoritesQuery],
+ );
const addFavoriteAgent = (agentId: string) => {
if (favorites.some((f) => f.agentId === agentId)) return;
@@ -89,25 +116,27 @@ export default function useFavorites() {
};
/**
- * Reorder favorites and persist the new order to the server.
+ * Reorder favorites and optionally persist the new order to the server.
* This combines state update and persistence to avoid race conditions
* where the closure captures stale state.
*/
- const reorderFavorites = (newFavorites: typeof favorites, persist = false) => {
- const cleaned = newFavorites.map((f) => {
- if (f.agentId) {
- return { agentId: f.agentId };
+ const reorderFavorites = useCallback(
+ async (newFavorites: typeof favorites, persist = false) => {
+ const cleaned = cleanFavorites(newFavorites);
+ setFavorites(cleaned);
+ if (persist) {
+ try {
+ await updateFavoritesMutation.mutateAsync(cleaned);
+ } catch (error) {
+ console.error('Error reordering favorites:', error);
+ showToast({ message: localize('com_ui_error'), status: 'error' });
+ // Refetch to resync state with server
+ getFavoritesQuery.refetch();
+ }
}
- if (f.model && f.endpoint) {
- return { model: f.model, endpoint: f.endpoint };
- }
- return f;
- });
- setFavorites(cleaned);
- if (persist) {
- updateFavoritesMutation.mutate(cleaned);
- }
- };
+ },
+ [setFavorites, updateFavoritesMutation, showToast, localize, getFavoritesQuery],
+ );
return {
favorites,