feat: Enhance favorites management by adding loading and error states, and refactor related hooks and components

This commit is contained in:
Marco Beretta
2025-11-25 22:07:14 +01:00
parent 282b5e574e
commit 7b0b469206
5 changed files with 79 additions and 33 deletions

View File

@@ -32,7 +32,11 @@ jest.mock('~/hooks', () => ({
addFavoriteModel: jest.fn(),
removeFavoriteModel: jest.fn(),
reorderFavorites: jest.fn(),
persistFavorites: jest.fn(),
isLoading: false,
isError: false,
isUpdating: false,
fetchError: null,
updateError: null,
})),
}));

View File

@@ -90,7 +90,7 @@ const Conversations: FC<ConversationsProps> = ({
setIsChatsExpanded,
}) => {
const localize = useLocalize();
const { favorites } = useFavorites();
const { favorites, isLoading: isFavoritesLoading } = useFavorites();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const convoHeight = isSmallScreen ? 44 : 34;
@@ -152,13 +152,13 @@ const Conversations: FC<ConversationsProps> = ({
useEffect(() => {
if (cache) {
// Only clear the favorites row
// Clear the favorites row when favorites change or finish loading
cache.clear(0, 0);
if (containerRef.current && 'recomputeRowHeights' in containerRef.current) {
containerRef.current.recomputeRowHeights(0);
}
}
}, [favorites.length, cache, containerRef]);
}, [favorites.length, isFavoritesLoading, cache, containerRef]);
const rowRenderer = useCallback(
({ index, key, parent, style }) => {

View File

@@ -1,4 +1,4 @@
import React, { useRef, useCallback, useMemo, useContext } from 'react';
import React, { useRef, useCallback, useMemo, useContext, useEffect } from 'react';
import { LayoutGrid } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useDrag, useDrop } from 'react-dnd';
@@ -100,7 +100,7 @@ export default function FavoritesList({
const localize = useLocalize();
const queryClient = useQueryClient();
const authContext = useContext(AuthContext);
const { favorites, reorderFavorites, persistFavorites } = useFavorites();
const { favorites, reorderFavorites, isLoading: isFavoritesLoading } = useFavorites();
const hasAccessToAgents = useHasAccess({
permissionType: PermissionTypes.AGENTS,
@@ -171,19 +171,33 @@ export default function FavoritesList({
return map;
}, [agentQueries, queryClient]);
const draggedFavoritesRef = useRef(favorites);
const moveItem = useCallback(
(dragIndex: number, hoverIndex: number) => {
const newFavorites = [...favorites];
const newFavorites = [...draggedFavoritesRef.current];
const [draggedItem] = newFavorites.splice(dragIndex, 1);
newFavorites.splice(hoverIndex, 0, draggedItem);
reorderFavorites(newFavorites);
draggedFavoritesRef.current = newFavorites;
reorderFavorites(newFavorites, false);
},
[favorites, reorderFavorites],
[reorderFavorites],
);
const handleDrop = useCallback(() => {
persistFavorites(favorites);
}, [favorites, persistFavorites]);
// Persist the final order using the ref which has the latest state
reorderFavorites(draggedFavoritesRef.current, true);
}, [reorderFavorites]);
// Keep ref in sync when favorites change from external sources
useEffect(() => {
draggedFavoritesRef.current = favorites;
}, [favorites]);
// Show nothing while favorites are loading to prevent layout shifts
if (isFavoritesLoading) {
return null;
}
// If no favorites and no marketplace to show, return null
if (favorites.length === 0 && !showAgentMarketplace) {
@@ -193,7 +207,7 @@ export default function FavoritesList({
return (
<div className="mb-2 flex flex-col pb-2">
<div className="mt-1 flex flex-col gap-1">
{/* Agent Marketplace button - identical styling to favorite items */}
{/* Agent Marketplace button */}
{showAgentMarketplace && (
<div
className="group relative flex w-full cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm text-text-primary hover:bg-surface-active-alt"

View File

@@ -1,8 +1,11 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { UseQueryOptions } from '@tanstack/react-query';
import { dataService } from 'librechat-data-provider';
import type { FavoritesState } from '~/store/favorites';
export const useGetFavoritesQuery = (config?: any) => {
export const useGetFavoritesQuery = (
config?: Omit<UseQueryOptions<FavoritesState, Error>, 'queryKey' | 'queryFn'>,
) => {
return useQuery<FavoritesState>(
['favorites'],
() => dataService.getFavorites() as Promise<FavoritesState>,

View File

@@ -1,9 +1,19 @@
import { useEffect } from 'react';
import { useRecoilState } from 'recoil';
import type { Favorite } from '~/store/favorites';
import store from '~/store';
import { useGetFavoritesQuery, useUpdateFavoritesMutation } from '~/data-provider';
/**
* Hook for managing user favorites (pinned agents and models).
*
* Favorites are synchronized with the server via `/api/user/settings/favorites`.
* Each favorite is either:
* - An agent: `{ agentId: string }`
* - A model: `{ model: string, endpoint: string }`
*
* @returns Object containing favorites state and helper methods for
* adding, removing, toggling, reordering, and checking favorites.
*/
export default function useFavorites() {
const [favorites, setFavorites] = useRecoilState(store.favorites);
const getFavoritesQuery = useGetFavoritesQuery();
@@ -12,18 +22,8 @@ export default function useFavorites() {
useEffect(() => {
if (getFavoritesQuery.data) {
if (Array.isArray(getFavoritesQuery.data)) {
const mapped = getFavoritesQuery.data.map(
(f: Favorite & { type?: string; id?: string }) => {
if (f.agentId || (f.model && f.endpoint)) return f;
if (f.type === 'agent' && f.id) return { agentId: f.id };
// Drop label and map legacy model format
if (f.type === 'model') return { model: f.model, endpoint: f.endpoint };
return f;
},
);
setFavorites(mapped);
setFavorites(getFavoritesQuery.data);
} else {
// Handle legacy format or invalid data
setFavorites([]);
}
}
@@ -61,7 +61,10 @@ export default function useFavorites() {
saveFavorites(newFavorites);
};
const isFavoriteAgent = (agentId: string) => {
const isFavoriteAgent = (agentId: string | undefined | null) => {
if (!agentId) {
return false;
}
return favorites.some((f) => f.agentId === agentId);
};
@@ -85,12 +88,25 @@ export default function useFavorites() {
}
};
const reorderFavorites = (newFavorites: typeof favorites) => {
setFavorites(newFavorites);
};
const persistFavorites = (newFavorites: typeof favorites) => {
updateFavoritesMutation.mutate(newFavorites);
/**
* Reorder favorites and 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 };
}
if (f.model && f.endpoint) {
return { model: f.model, endpoint: f.endpoint };
}
return f;
});
setFavorites(cleaned);
if (persist) {
updateFavoritesMutation.mutate(cleaned);
}
};
return {
@@ -104,6 +120,15 @@ export default function useFavorites() {
toggleFavoriteAgent,
toggleFavoriteModel,
reorderFavorites,
persistFavorites,
/** Whether the favorites query is currently loading */
isLoading: getFavoritesQuery.isLoading,
/** Whether there was an error fetching favorites */
isError: getFavoritesQuery.isError,
/** Whether the update mutation is in progress */
isUpdating: updateFavoritesMutation.isLoading,
/** Error from fetching favorites, if any */
fetchError: getFavoritesQuery.error,
/** Error from updating favorites, if any */
updateError: updateFavoritesMutation.error,
};
}