From 7b0b46920649ef055bf85130d9a973bd8f4c0e80 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 25 Nov 2025 22:07:14 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Enhance=20favorites=20manag?= =?UTF-8?q?ement=20by=20adding=20loading=20and=20error=20states,=20and=20r?= =?UTF-8?q?efactor=20related=20hooks=20and=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Agents/tests/AgentDetail.spec.tsx | 6 +- .../Conversations/Conversations.tsx | 6 +- .../Nav/Favorites/FavoritesList.tsx | 30 ++++++--- client/src/data-provider/Favorites.ts | 5 +- client/src/hooks/useFavorites.ts | 65 +++++++++++++------ 5 files changed, 79 insertions(+), 33 deletions(-) diff --git a/client/src/components/Agents/tests/AgentDetail.spec.tsx b/client/src/components/Agents/tests/AgentDetail.spec.tsx index d4635c38f..0a1afffea 100644 --- a/client/src/components/Agents/tests/AgentDetail.spec.tsx +++ b/client/src/components/Agents/tests/AgentDetail.spec.tsx @@ -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, })), })); diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index 3f47cd66e..489a5251a 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -90,7 +90,7 @@ const Conversations: FC = ({ 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 = ({ 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 }) => { diff --git a/client/src/components/Nav/Favorites/FavoritesList.tsx b/client/src/components/Nav/Favorites/FavoritesList.tsx index e995a3bd5..f94a5edac 100644 --- a/client/src/components/Nav/Favorites/FavoritesList.tsx +++ b/client/src/components/Nav/Favorites/FavoritesList.tsx @@ -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 (
- {/* Agent Marketplace button - identical styling to favorite items */} + {/* Agent Marketplace button */} {showAgentMarketplace && (
{ +export const useGetFavoritesQuery = ( + config?: Omit, 'queryKey' | 'queryFn'>, +) => { return useQuery( ['favorites'], () => dataService.getFavorites() as Promise, diff --git a/client/src/hooks/useFavorites.ts b/client/src/hooks/useFavorites.ts index 2e81919be..d3d1b560e 100644 --- a/client/src/hooks/useFavorites.ts +++ b/client/src/hooks/useFavorites.ts @@ -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, }; }