From b6e5ea5d3334e9c9e62373525db40bf96d9358f0 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 4 Dec 2025 20:41:52 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=8C=20feat:=20Pin=20Agents=20and=20Mod?= =?UTF-8?q?els=20in=20the=20Sidebar=20(#10634)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * đŸĒĻ refactor: Remove Legacy Code (#10533) * đŸ—‘ī¸ chore: Remove unused Legacy Provider clients and related helpers * Deleted OpenAIClient and GoogleClient files along with their associated tests. * Removed references to these clients in the clients index file. * Cleaned up typedefs by removing the OpenAISpecClient export. * Updated chat controllers to use the OpenAI SDK directly instead of the removed client classes. * chore/remove-openapi-specs * đŸ—‘ī¸ chore: Remove unused mergeSort and misc utility functions * Deleted mergeSort.js and misc.js files as they are no longer needed. * Removed references to cleanUpPrimaryKeyValue in messages.js and adjusted related logic. * Updated mongoMeili.ts to eliminate local implementations of removed functions. * chore: remove legacy endpoints * chore: remove all plugins endpoint related code * chore: remove unused prompt handling code and clean up imports * Deleted handleInputs.js and instructions.js files as they are no longer needed. * Removed references to these files in the prompts index.js. * Updated docker-compose.yml to simplify reverse proxy configuration. * chore: remove unused LightningIcon import from Icons.tsx * chore: clean up translation.json by removing deprecated and unused keys * chore: update Jest configuration and remove unused mock file * Simplified the setupFiles array in jest.config.js by removing the fetchEventSource mock. * Deleted the fetchEventSource.js mock file as it is no longer needed. * fix: simplify endpoint type check in Landing and ConversationStarters components * Updated the endpoint type check to use strict equality for better clarity and performance. * Ensured consistency in the handling of the azureOpenAI endpoint across both components. * chore: remove unused dependencies from package.json and package-lock.json * chore: remove legacy EditController, associated routes and imports * chore: update banResponse logic to refine request handling for banned users * chore: remove unused validateEndpoint middleware and its references * chore: remove unused 'res' parameter from initializeClient in multiple endpoint files * chore: remove unused 'isSmallScreen' prop from BookmarkNav and NewChat components; clean up imports in ArchivedChatsTable and useSetIndexOptions hooks; enhance localization in PromptVersions * chore: remove unused import of Constants and TMessage from MobileNav; retain only necessary QueryKeys import * chore: remove unused TResPlugin type and related references; clean up imports in types and schemas * đŸĒĻ refactor: Remove Legacy Code (#10533) * đŸ—‘ī¸ chore: Remove unused Legacy Provider clients and related helpers * Deleted OpenAIClient and GoogleClient files along with their associated tests. * Removed references to these clients in the clients index file. * Cleaned up typedefs by removing the OpenAISpecClient export. * Updated chat controllers to use the OpenAI SDK directly instead of the removed client classes. * chore/remove-openapi-specs * đŸ—‘ī¸ chore: Remove unused mergeSort and misc utility functions * Deleted mergeSort.js and misc.js files as they are no longer needed. * Removed references to cleanUpPrimaryKeyValue in messages.js and adjusted related logic. * Updated mongoMeili.ts to eliminate local implementations of removed functions. * chore: remove legacy endpoints * chore: remove all plugins endpoint related code * chore: remove unused prompt handling code and clean up imports * Deleted handleInputs.js and instructions.js files as they are no longer needed. * Removed references to these files in the prompts index.js. * Updated docker-compose.yml to simplify reverse proxy configuration. * chore: remove unused LightningIcon import from Icons.tsx * chore: clean up translation.json by removing deprecated and unused keys * chore: update Jest configuration and remove unused mock file * Simplified the setupFiles array in jest.config.js by removing the fetchEventSource mock. * Deleted the fetchEventSource.js mock file as it is no longer needed. * fix: simplify endpoint type check in Landing and ConversationStarters components * Updated the endpoint type check to use strict equality for better clarity and performance. * Ensured consistency in the handling of the azureOpenAI endpoint across both components. * chore: remove unused dependencies from package.json and package-lock.json * chore: remove legacy EditController, associated routes and imports * chore: update banResponse logic to refine request handling for banned users * chore: remove unused validateEndpoint middleware and its references * chore: remove unused 'res' parameter from initializeClient in multiple endpoint files * chore: remove unused 'isSmallScreen' prop from BookmarkNav and NewChat components; clean up imports in ArchivedChatsTable and useSetIndexOptions hooks; enhance localization in PromptVersions * chore: remove unused import of Constants and TMessage from MobileNav; retain only necessary QueryKeys import * chore: remove unused TResPlugin type and related references; clean up imports in types and schemas * đŸ“Ļ chore: Bump Express.js to v5 (#10671) * chore: update express to version 5.1.0 in package.json * chore: update express-rate-limit to version 8.2.1 in package.json and package-lock.json * fix: Enhance server startup error handling in experimental and index files * Added error handling for server startup in both experimental.js and index.js to log errors and exit the process if the server fails to start. * Updated comments in openidStrategy.js to clarify the purpose of the CustomOpenIDStrategy class and its relation to Express version changes. * chore: Implement rate limiting for all POST routes excluding /speech, required for express v5 * Added middleware to apply IP and user rate limiters to all POST requests, ensuring that the /speech route remains unaffected. * Enhanced code clarity with comments explaining the new rate limiting logic. * chore: Enable writable req.query for mongoSanitize compatibility in Express 5 * chore: Ensure req.body exists in multiple middleware and route files for Express 5 compatibility * đŸ—Ŗ feat: MCP Status Accessibility Improvements (#10738) * feat: make MultiSelect highlight same opacity as other focus highlights in app * feat: add better screenreader announcements for mcp server and variable states * feat: memoize fullTitle calculation * đŸĒ¨ feat: Add PROXY support for AWS Bedrock endpoints (#8871) * feat: added PROXY support for AWS Bedrock endpoint * chore: explicit install of new packages required for bedrock proxy --------- Co-authored-by: Danny Avila * ✨ feat: Implement Favorites functionality with controllers, hooks, and UI components * ✨ feat: Refactor Favorites functionality to support new data structure and enhance UI interactions * ✨ feat: Add endpoint to new conversation for agent favorites * ✨ feat: Enhance Conversations and Favorites components with expanded functionality and improved UI interactions * ✨ feat: Remove 'Pinned' label from UI translations for cleaner interface * feat: clean up comments and improve code readability in favorites and agent components; bump @librechat/data-schemas to 0.0.24 * ✨ feat: Enhance favorites management with validation, update data structure, and improve UI interactions * ✨ feat: Simplify rendering logic in EndpointModelItem and optimize useEffect dependencies in Conversations component * ✨ test: Update favorites mock implementation and improve button focus styles in AgentDetail tests * ✨ feat: Enhance favorites management by adding loading and error states, and refactor related hooks and components * ✨ feat: Add loading skeletons for favorites while agents are being fetched * ✨ feat: Improve loading experience in FavoritesList by adding skeleton placeholders for favorites and marketplace * feat: Optimize cache handling in Conversations and enhance FavoritesList to notify height changes on loading completion * ✨ feat: Add loading skeleton for SearchBar in Nav component and update agent avatar fallback icon to Feather * feat: Refactor FavoritesController validation, streamline ModelSelector component, and enhance EndpointModelItem with selection state * feat: Adjust padding in Conversations and FavoritesList components for improved layout consistency * feat: Refactor FavoritesController to use model methods for user updates and retrieval * feat: Enhance Favorites functionality with validation, cleanup, and improved error handling * tests: Update AgentCard and agent utilities to use Feather icon fallback instead of Bot icon * refactor: Remove collapsible animation styles from CSS * feat: Migrate favorites state management from Recoil to Jotai * fix: Correct type definition in useGetFavoritesQuery and ensure useFavorites is exported * refactor: Simplify AuthField component by removing TooltipAnchor and directly rendering Label * fix: Ensure favorites are always an array and update references in FavoritesList * style: Update Conversation component styles for improved UI consistency * feat: re-integrate AuthContext to manage agent marketplace visibility based on authentication state * fix: Improve optimistic updates in favorites mutation handling * feat: Implement error handling for favorites limit and consolidate marketplace access logic * fix: package-lock --------- Co-authored-by: Danny Avila Co-authored-by: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Co-authored-by: Arthur Barrett --- api/server/controllers/FavoritesController.js | 99 ++++++ api/server/routes/settings.js | 13 + api/server/routes/user.js | 3 + client/src/components/Agents/AgentCard.tsx | 87 ++--- client/src/components/Agents/AgentDetail.tsx | 52 +-- .../Agents/tests/AgentCard.spec.tsx | 8 +- .../Agents/tests/AgentDetail.spec.tsx | 19 +- .../Chat/Menus/Endpoints/ModelSelector.tsx | 1 - .../components/EndpointModelItem.tsx | 89 ++++-- .../Conversations/Conversations.tsx | 145 +++++++-- client/src/components/Conversations/Convo.tsx | 9 +- .../components/Conversations/ConvoLink.tsx | 6 +- .../components/Nav/AgentMarketplaceButton.tsx | 25 +- .../components/Nav/Favorites/FavoriteItem.tsx | 150 +++++++++ .../Nav/Favorites/FavoritesList.tsx | 299 ++++++++++++++++++ client/src/components/Nav/Nav.tsx | 53 ++-- client/src/components/Nav/SearchBar.tsx | 5 +- client/src/data-provider/Favorites.ts | 44 +++ client/src/data-provider/index.ts | 1 + client/src/hooks/Nav/index.ts | 1 + client/src/hooks/Nav/useShowMarketplace.ts | 37 +++ client/src/hooks/index.ts | 1 + client/src/hooks/useFavorites.ts | 206 ++++++++++++ client/src/locales/en/translation.json | 6 +- client/src/store/favorites.ts | 19 ++ client/src/store/index.ts | 1 + client/src/utils/__tests__/agents.spec.tsx | 21 +- client/src/utils/agents.tsx | 64 +++- packages/data-provider/src/data-service.ts | 14 + packages/data-schemas/src/schema/user.ts | 11 + packages/data-schemas/src/types/user.ts | 5 + 31 files changed, 1310 insertions(+), 184 deletions(-) create mode 100644 api/server/controllers/FavoritesController.js create mode 100644 api/server/routes/settings.js create mode 100644 client/src/components/Nav/Favorites/FavoriteItem.tsx create mode 100644 client/src/components/Nav/Favorites/FavoritesList.tsx create mode 100644 client/src/data-provider/Favorites.ts create mode 100644 client/src/hooks/Nav/useShowMarketplace.ts create mode 100644 client/src/hooks/useFavorites.ts create mode 100644 client/src/store/favorites.ts diff --git a/api/server/controllers/FavoritesController.js b/api/server/controllers/FavoritesController.js new file mode 100644 index 0000000000..186dd810bf --- /dev/null +++ b/api/server/controllers/FavoritesController.js @@ -0,0 +1,99 @@ +const { updateUser, getUserById } = require('~/models'); + +const MAX_FAVORITES = 50; +const MAX_STRING_LENGTH = 256; + +const updateFavoritesController = async (req, res) => { + try { + const { favorites } = req.body; + const userId = req.user.id; + + if (!favorites) { + return res.status(400).json({ message: 'Favorites data is required' }); + } + + if (!Array.isArray(favorites)) { + return res.status(400).json({ message: 'Favorites must be an array' }); + } + + if (favorites.length > MAX_FAVORITES) { + return res.status(400).json({ + code: 'MAX_FAVORITES_EXCEEDED', + message: `Maximum ${MAX_FAVORITES} favorites allowed`, + limit: MAX_FAVORITES, + }); + } + + 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', + }); + } + + if (hasAgent && hasModel) { + return res.status(400).json({ + message: 'Favorite cannot have both agentId and model/endpoint', + }); + } + } + + const user = await updateUser(userId, { favorites }); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + res.status(200).json(user.favorites); + } catch (error) { + console.error('Error updating favorites:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +const getFavoritesController = async (req, res) => { + try { + const userId = req.user.id; + const user = await getUserById(userId, 'favorites'); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + let favorites = user.favorites || []; + + if (!Array.isArray(favorites)) { + favorites = []; + await updateUser(userId, { favorites: [] }); + } + + res.status(200).json(favorites); + } catch (error) { + console.error('Error fetching favorites:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +module.exports = { + updateFavoritesController, + getFavoritesController, +}; diff --git a/api/server/routes/settings.js b/api/server/routes/settings.js new file mode 100644 index 0000000000..22162fed4e --- /dev/null +++ b/api/server/routes/settings.js @@ -0,0 +1,13 @@ +const express = require('express'); +const { + updateFavoritesController, + getFavoritesController, +} = require('~/server/controllers/FavoritesController'); +const { requireJwtAuth } = require('~/server/middleware'); + +const router = express.Router(); + +router.get('/favorites', requireJwtAuth, getFavoritesController); +router.post('/favorites', requireJwtAuth, updateFavoritesController); + +module.exports = router; diff --git a/api/server/routes/user.js b/api/server/routes/user.js index 7efab9d026..1858be22be 100644 --- a/api/server/routes/user.js +++ b/api/server/routes/user.js @@ -15,8 +15,11 @@ const { requireJwtAuth, } = require('~/server/middleware'); +const settings = require('./settings'); + const router = express.Router(); +router.use('/settings', settings); router.get('/', requireJwtAuth, getUserController); router.get('/terms', requireJwtAuth, getTermsStatusController); router.post('/terms/accept', requireJwtAuth, acceptTermsController); diff --git a/client/src/components/Agents/AgentCard.tsx b/client/src/components/Agents/AgentCard.tsx index 29b85c5dea..6a81a1645e 100644 --- a/client/src/components/Agents/AgentCard.tsx +++ b/client/src/components/Agents/AgentCard.tsx @@ -55,52 +55,55 @@ const AgentCard: React.FC = ({ agent, onClick, className = '' }) } }} > - {/* Two column layout */} -
- {/* Left column: Avatar and Category */} -
-
{renderAgentAvatar(agent, { size: 'sm' })}
+
+
+ {/* Left column: Avatar and Category */} +
+
{renderAgentAvatar(agent, { size: 'sm' })}
- {/* Category tag */} - {agent.category && ( -
- -
- )} -
- - {/* Right column: Name, description, and other content */} -
-
- {/* Agent name */} - - - {/* Agent description */} -

- {agent.description ?? ''} -

+ {/* Category tag */} + {agent.category && ( +
+ +
+ )}
- {/* Owner info - moved to bottom right */} - {(() => { - const displayName = getContactDisplayName(agent); - if (displayName) { - return ( -
-
- + {/* Right column: Name, description, and other content */} +
+
+ {/* Agent name */} + + + {/* Agent description */} +

+ {agent.description ?? ''} +

+
+ + {/* Owner info */} + {(() => { + const displayName = getContactDisplayName(agent); + if (displayName) { + return ( +
+
+ +
-
- ); - } - return null; - })()} + ); + } + return null; + })()} +
diff --git a/client/src/components/Agents/AgentDetail.tsx b/client/src/components/Agents/AgentDetail.tsx index 7047ad67a6..1820eed8b9 100644 --- a/client/src/components/Agents/AgentDetail.tsx +++ b/client/src/components/Agents/AgentDetail.tsx @@ -1,5 +1,5 @@ import React, { useRef } from 'react'; -import { Link } from 'lucide-react'; +import { Link, Pin, PinOff } from 'lucide-react'; import { useQueryClient } from '@tanstack/react-query'; import { OGDialog, OGDialogContent, Button, useToastContext } from '@librechat/client'; import { @@ -11,8 +11,8 @@ import { AgentListResponse, } from 'librechat-data-provider'; import type t from 'librechat-data-provider'; +import { useLocalize, useDefaultConvo, useFavorites } from '~/hooks'; import { renderAgentAvatar, clearMessagesCache } from '~/utils'; -import { useLocalize, useDefaultConvo } from '~/hooks'; import { useChatContext } from '~/Providers'; interface SupportContact { @@ -39,6 +39,14 @@ const AgentDetail: React.FC = ({ agent, isOpen, onClose }) => const dialogRef = useRef(null); const getDefaultConversation = useDefaultConvo(); const { conversation, newConversation } = useChatContext(); + const { isFavoriteAgent, toggleFavoriteAgent } = useFavorites(); + const isFavorite = isFavoriteAgent(agent?.id); + + const handleFavoriteClick = () => { + if (agent) { + toggleFavoriteAgent(agent.id); + } + }; /** * Navigate to chat with the selected agent @@ -133,42 +141,48 @@ const AgentDetail: React.FC = ({ agent, isOpen, onClose }) => return ( !open && onClose()}> - {/* Copy link button - positioned next to close button */} - - - {/* Agent avatar - top center */} + {/* Agent avatar */}
{renderAgentAvatar(agent, { size: 'xl' })}
- {/* Agent name - center aligned below image */} + {/* Agent name */}

{agent?.name || localize('com_agents_loading')}

- {/* Contact info - center aligned below name */} + {/* Contact info */} {agent?.support_contact && formatContact() && (
{localize('com_agents_contact')}: {formatContact()}
)} - {/* Agent description - below contact */} + {/* Agent description */}
{agent?.description}
{/* Action button */} -
+
+ + diff --git a/client/src/components/Agents/tests/AgentCard.spec.tsx b/client/src/components/Agents/tests/AgentCard.spec.tsx index 8bcf7fb1d8..71ab702909 100644 --- a/client/src/components/Agents/tests/AgentCard.spec.tsx +++ b/client/src/components/Agents/tests/AgentCard.spec.tsx @@ -104,7 +104,7 @@ describe('AgentCard', () => { expect(avatarImg).toHaveAttribute('src', '/string-avatar.png'); }); - it('displays Bot icon fallback when no avatar is provided', () => { + it('displays Feather icon fallback when no avatar is provided', () => { const agentWithoutAvatar = { ...mockAgent, avatar: undefined, @@ -112,9 +112,9 @@ describe('AgentCard', () => { render(); - // Check for Bot icon presence by looking for the svg with lucide-bot class - const botIcon = document.querySelector('.lucide-bot'); - expect(botIcon).toBeInTheDocument(); + // Check for Feather icon presence by looking for the svg with lucide-feather class + const featherIcon = document.querySelector('.lucide-feather'); + expect(featherIcon).toBeInTheDocument(); }); it('calls onClick when card is clicked', () => { diff --git a/client/src/components/Agents/tests/AgentDetail.spec.tsx b/client/src/components/Agents/tests/AgentDetail.spec.tsx index 833405c1e7..0a1afffea7 100644 --- a/client/src/components/Agents/tests/AgentDetail.spec.tsx +++ b/client/src/components/Agents/tests/AgentDetail.spec.tsx @@ -21,6 +21,23 @@ jest.mock('~/hooks', () => ({ useMediaQuery: jest.fn(() => false), // Mock as desktop by default useLocalize: jest.fn(), useDefaultConvo: jest.fn(), + useFavorites: jest.fn(() => ({ + favorites: [], + isFavoriteAgent: jest.fn(() => false), + toggleFavoriteAgent: jest.fn(), + isFavoriteModel: jest.fn(() => false), + toggleFavoriteModel: jest.fn(), + addFavoriteAgent: jest.fn(), + removeFavoriteAgent: jest.fn(), + addFavoriteModel: jest.fn(), + removeFavoriteModel: jest.fn(), + reorderFavorites: jest.fn(), + isLoading: false, + isError: false, + isUpdating: false, + fetchError: null, + updateError: null, + })), })); jest.mock('@librechat/client', () => ({ @@ -342,7 +359,7 @@ describe('AgentDetail', () => { renderWithProviders(); const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' }); - expect(copyLinkButton).toHaveClass('focus:outline-none', 'focus:ring-2'); + expect(copyLinkButton).toHaveClass('focus-visible:outline-none', 'focus-visible:ring-2'); }); }); diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx index bee5a38ad4..71caf4a3db 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx @@ -28,7 +28,6 @@ function ModelSelectorContent() { searchValue, searchResults, selectedValues, - // Functions setSearchValue, setSelectedValues, diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx index 67b8b522ee..32a480e726 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx @@ -1,9 +1,11 @@ import React from 'react'; -import { EarthIcon } from 'lucide-react'; +import { EarthIcon, Pin, PinOff } from 'lucide-react'; import { isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider'; -import type { Endpoint } from '~/common'; import { useModelSelectorContext } from '../ModelSelectorContext'; import { CustomMenuItem as MenuItem } from '../CustomMenu'; +import { useFavorites, useLocalize } from '~/hooks'; +import type { Endpoint } from '~/common'; +import { cn } from '~/utils'; interface EndpointModelItemProps { modelId: string | null; @@ -12,7 +14,10 @@ interface EndpointModelItemProps { } export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointModelItemProps) { + const localize = useLocalize(); const { handleSelectModel } = useModelSelectorContext(); + const { isFavoriteModel, toggleFavoriteModel, isFavoriteAgent, toggleFavoriteAgent } = + useFavorites(); let isGlobal = false; let modelName = modelId; const avatarUrl = endpoint?.modelIcons?.[modelId ?? ''] || null; @@ -32,31 +37,79 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod modelName = endpoint.assistantNames[modelId]; } + const isAgent = isAgentsEndpoint(endpoint.value); + const isFavorite = isAgent + ? isFavoriteAgent(modelId ?? '') + : isFavoriteModel(modelId ?? '', endpoint.value); + + const handleFavoriteClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!modelId) { + return; + } + + if (isAgent) { + toggleFavoriteAgent(modelId); + } else { + toggleFavoriteModel({ model: modelId, endpoint: endpoint.value }); + } + }; + + const renderAvatar = () => { + const isAgentOrAssistant = + isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value); + const showEndpointIcon = isAgentOrAssistant && endpoint.icon; + + const getContent = () => { + if (avatarUrl) { + return {modelName; + } + if (showEndpointIcon) { + return endpoint.icon; + } + return null; + }; + + const content = getContent(); + if (!content) { + return null; + } + + return ( +
+ {content} +
+ ); + }; + return ( handleSelectModel(endpoint, modelId ?? '')} - className="flex w-full cursor-pointer items-center justify-between rounded-lg px-2 text-sm" + className="group flex w-full cursor-pointer items-center justify-between rounded-lg px-2 text-sm" >
- {avatarUrl ? ( -
- {modelName -
- ) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) && - endpoint.icon ? ( -
- {endpoint.icon} -
- ) : null} - {modelName} - {isGlobal && ( - {modelName} + {isGlobal && } +
+
+ {isSelected && (
; moveToTop: () => void; toggleNav: () => void; - containerRef: React.RefObject; + containerRef: React.RefObject; loadMoreConversations: () => void; isLoading: boolean; isSearchLoading: boolean; + isChatsExpanded: boolean; + setIsChatsExpanded: (expanded: boolean) => void; } const LoadingSpinner = memo(() => { @@ -30,10 +36,13 @@ const LoadingSpinner = memo(() => { LoadingSpinner.displayName = 'LoadingSpinner'; -const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => { +const DateLabel: FC<{ groupName: string; isFirst?: boolean }> = memo(({ groupName, isFirst }) => { const localize = useLocalize(); return ( -

+

{localize(groupName as TranslationKeys) || groupName}

); @@ -42,6 +51,8 @@ const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => { DateLabel.displayName = 'DateLabel'; type FlattenedItem = + | { type: 'favorites' } + | { type: 'chats-header' } | { type: 'header'; groupName: string } | { type: 'convo'; convo: TConversation } | { type: 'loading' }; @@ -75,10 +86,19 @@ const Conversations: FC = ({ loadMoreConversations, isLoading, isSearchLoading, + isChatsExpanded, + setIsChatsExpanded, }) => { const localize = useLocalize(); + const search = useRecoilValue(store.search); + const { favorites, isLoading: isFavoritesLoading } = useFavorites(); const isSmallScreen = useMediaQuery('(max-width: 768px)'); const convoHeight = isSmallScreen ? 44 : 34; + const showAgentMarketplace = useShowMarketplace(); + + // Determine if FavoritesList will render content + const shouldShowFavorites = + !search.query && (isFavoritesLoading || favorites.length > 0 || showAgentMarketplace); const filteredConversations = useMemo( () => rawConversations.filter(Boolean) as TConversation[], @@ -92,39 +112,79 @@ const Conversations: FC = ({ const flattenedItems = useMemo(() => { const items: FlattenedItem[] = []; - groupedConversations.forEach(([groupName, convos]) => { - items.push({ type: 'header', groupName }); - items.push(...convos.map((convo) => ({ type: 'convo' as const, convo }))); - }); + // Only include favorites row if FavoritesList will render content + if (shouldShowFavorites) { + items.push({ type: 'favorites' }); + } + items.push({ type: 'chats-header' }); - if (isLoading) { - items.push({ type: 'loading' } as any); + if (isChatsExpanded) { + groupedConversations.forEach(([groupName, convos]) => { + items.push({ type: 'header', groupName }); + items.push(...convos.map((convo) => ({ type: 'convo' as const, convo }))); + }); + + if (isLoading) { + items.push({ type: 'loading' } as any); + } } return items; - }, [groupedConversations, isLoading]); + }, [groupedConversations, isLoading, isChatsExpanded, shouldShowFavorites]); + // Store flattenedItems in a ref for keyMapper to access without recreating cache + const flattenedItemsRef = useRef(flattenedItems); + flattenedItemsRef.current = flattenedItems; + + // Create a stable cache that doesn't depend on flattenedItems const cache = useMemo( () => new CellMeasurerCache({ fixedWidth: true, defaultHeight: convoHeight, keyMapper: (index) => { - const item = flattenedItems[index]; + const item = flattenedItemsRef.current[index]; + if (!item) { + return `unknown-${index}`; + } + if (item.type === 'favorites') { + return 'favorites'; + } + if (item.type === 'chats-header') { + return 'chats-header'; + } if (item.type === 'header') { - return `header-${index}`; + return `header-${item.groupName}`; } if (item.type === 'convo') { return `convo-${item.convo.conversationId}`; } if (item.type === 'loading') { - return `loading-${index}`; + return 'loading'; } return `unknown-${index}`; }, }), - [flattenedItems, convoHeight], + [convoHeight], ); + // Debounced function to clear cache and recompute heights + const clearFavoritesCache = useCallback(() => { + if (cache) { + cache.clear(0, 0); + if (containerRef.current && 'recomputeRowHeights' in containerRef.current) { + containerRef.current.recomputeRowHeights(0); + } + } + }, [cache, containerRef]); + + // Clear cache when favorites change + useEffect(() => { + const frameId = requestAnimationFrame(() => { + clearFavoritesCache(); + }); + return () => cancelAnimationFrame(frameId); + }, [favorites.length, isFavoritesLoading, clearFavoritesCache]); + const rowRenderer = useCallback( ({ index, key, parent, style }) => { const item = flattenedItems[index]; @@ -140,8 +200,36 @@ const Conversations: FC = ({ ); } let rendering: JSX.Element; - if (item.type === 'header') { - rendering = ; + if (item.type === 'favorites') { + rendering = ( + + ); + } else if (item.type === 'chats-header') { + rendering = ( + + ); + } else if (item.type === 'header') { + // 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 = ( @@ -150,14 +238,25 @@ const Conversations: FC = ({ return ( {({ registerChild }) => ( -
+
{rendering}
)} ); }, - [cache, flattenedItems, moveToTop, toggleNav], + [ + cache, + flattenedItems, + moveToTop, + toggleNav, + clearFavoritesCache, + isSmallScreen, + isChatsExpanded, + localize, + setIsChatsExpanded, + shouldShowFavorites, + ], ); const getRowHeight = useCallback( @@ -180,7 +279,7 @@ const Conversations: FC = ({ ); return ( -
+
{isSearchLoading ? (
@@ -191,7 +290,7 @@ const Conversations: FC = ({ {({ width, height }) => ( } + ref={containerRef} width={width} height={height} deferredMeasurementCache={cache} diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index d4808089c4..53384bb145 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -132,10 +132,10 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co return (
void; isSmallScreen: boolean; @@ -12,6 +13,7 @@ interface ConvoLinkProps { const ConvoLink: React.FC = ({ isActiveConvo, + isPopoverActive, title, onRename, isSmallScreen, @@ -22,7 +24,7 @@ const ConvoLink: React.FC = ({
= ({
{ 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/FavoriteItem.tsx b/client/src/components/Nav/Favorites/FavoriteItem.tsx new file mode 100644 index 0000000000..74418e71fb --- /dev/null +++ b/client/src/components/Nav/Favorites/FavoriteItem.tsx @@ -0,0 +1,150 @@ +import React, { useState } from 'react'; +import * as Menu from '@ariakit/react/menu'; +import { useNavigate } from 'react-router-dom'; +import { Ellipsis, PinOff } from 'lucide-react'; +import { DropdownPopup } from '@librechat/client'; +import { EModelEndpoint } from 'librechat-data-provider'; +import type { FavoriteModel } from '~/store/favorites'; +import type t from 'librechat-data-provider'; +import EndpointIcon from '~/components/Endpoints/EndpointIcon'; +import { useNewConvo, useFavorites, useLocalize } from '~/hooks'; +import { renderAgentAvatar, cn } from '~/utils'; + +type FavoriteItemProps = { + item: t.Agent | FavoriteModel; + type: 'agent' | 'model'; +}; + +export default function FavoriteItem({ item, type }: FavoriteItemProps) { + const navigate = useNavigate(); + const localize = useLocalize(); + const { newConversation } = useNewConvo(); + const { removeFavoriteAgent, removeFavoriteModel } = useFavorites(); + const [isPopoverActive, setIsPopoverActive] = useState(false); + + const handleClick = (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest('[data-testid="favorite-options-button"]')) { + return; + } + + if (type === 'agent') { + const agent = item as t.Agent; + newConversation({ + template: { + ...agent, + endpoint: EModelEndpoint.agents, + agent_id: agent.id, + }, + preset: { + ...agent, + endpoint: EModelEndpoint.agents, + agent_id: agent.id, + }, + }); + navigate(`/c/new`); + } else { + const model = item as FavoriteModel; + newConversation({ + template: { + endpoint: model.endpoint, + model: model.model, + }, + preset: { + endpoint: model.endpoint, + model: model.model, + }, + }); + navigate(`/c/new`); + } + }; + + const handleRemove = (e: React.MouseEvent) => { + e.stopPropagation(); + if (type === 'agent') { + removeFavoriteAgent((item as t.Agent).id); + } else { + const model = item as FavoriteModel; + removeFavoriteModel(model.model, model.endpoint); + } + setIsPopoverActive(false); + }; + + const renderIcon = () => { + if (type === 'agent') { + return renderAgentAvatar(item as t.Agent, { size: 'icon', className: 'mr-2' }); + } + const model = item as FavoriteModel; + return ( +
+ +
+ ); + }; + + const getName = () => { + if (type === 'agent') { + return (item as t.Agent).name; + } + return (item as FavoriteModel).model; + }; + + const menuId = React.useId(); + + const dropdownItems = [ + { + label: localize('com_ui_unpin'), + onClick: handleRemove, + icon: , + }, + ]; + + return ( +
+
+ {renderIcon()} + {getName()} +
+ +
e.stopPropagation()} + > + + + + } + items={dropdownItems} + menuId={menuId} + /> +
+
+ ); +} diff --git a/client/src/components/Nav/Favorites/FavoritesList.tsx b/client/src/components/Nav/Favorites/FavoritesList.tsx new file mode 100644 index 0000000000..1aa71cbbaa --- /dev/null +++ b/client/src/components/Nav/Favorites/FavoritesList.tsx @@ -0,0 +1,299 @@ +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 type { InfiniteData } from '@tanstack/react-query'; +import type t from 'librechat-data-provider'; +import { useFavorites, useLocalize, useShowMarketplace } from '~/hooks'; +import FavoriteItem from './FavoriteItem'; +import store from '~/store'; + +const FavoriteItemSkeleton = () => ( +
+ + +
+); + +const MarketplaceSkeleton = () => ( +
+ + +
+); + +interface DraggableFavoriteItemProps { + id: string; + index: number; + moveItem: (dragIndex: number, hoverIndex: number) => void; + onDrop: () => void; + children: React.ReactNode; +} + +const DraggableFavoriteItem = ({ + id, + index, + moveItem, + onDrop, + children, +}: DraggableFavoriteItemProps) => { + const ref = useRef(null); + const [{ handlerId }, drop] = useDrop<{ index: number; id: string }, unknown, { handlerId: any }>( + { + accept: 'favorite-item', + collect(monitor) { + return { + handlerId: monitor.getHandlerId(), + }; + }, + hover(item, monitor) { + if (!ref.current) { + return; + } + const dragIndex = item.index; + const hoverIndex = index; + + if (dragIndex === hoverIndex) { + return; + } + + const hoverBoundingRect = ref.current?.getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + if (!clientOffset) { + return; + } + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) { + return; + } + + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) { + return; + } + + moveItem(dragIndex, hoverIndex); + item.index = hoverIndex; + }, + }, + ); + + const [{ isDragging }, drag] = useDrag({ + type: 'favorite-item', + item: () => { + return { id, index }; + }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + end: () => { + onDrop(); + }, + }); + + const opacity = isDragging ? 0 : 1; + drag(drop(ref)); + + return ( +
+ {children} +
+ ); +}; + +export default function FavoritesList({ + isSmallScreen, + toggleNav, + onHeightChange, +}: { + isSmallScreen?: boolean; + toggleNav?: () => void; + /** Callback when the list height might have changed (e.g., agents finished loading) */ + onHeightChange?: () => void; +}) { + const navigate = useNavigate(); + const localize = useLocalize(); + const queryClient = useQueryClient(); + const search = useRecoilValue(store.search); + const { favorites, reorderFavorites, isLoading: isFavoritesLoading } = useFavorites(); + const showAgentMarketplace = useShowMarketplace(); + + const handleAgentMarketplace = useCallback(() => { + navigate('/agents'); + if (isSmallScreen && toggleNav) { + toggleNav(); + } + }, [navigate, isSmallScreen, toggleNav]); + + // Ensure favorites is always an array (could be corrupted in localStorage) + const safeFavorites = useMemo(() => (Array.isArray(favorites) ? favorites : []), [favorites]); + + const agentIds = safeFavorites.map((f) => f.agentId).filter(Boolean) as string[]; + + const agentQueries = useQueries({ + queries: agentIds.map((agentId) => ({ + queryKey: [QueryKeys.agent, agentId], + queryFn: () => dataService.getAgentById({ agent_id: agentId }), + staleTime: 1000 * 60 * 5, + })), + }); + + const isAgentsLoading = agentIds.length > 0 && agentQueries.some((q) => q.isLoading); + + useEffect(() => { + if (!isAgentsLoading && onHeightChange) { + onHeightChange(); + } + }, [isAgentsLoading, onHeightChange]); + const agentsMap = useMemo(() => { + const map: Record = {}; + + const addToMap = (agent: t.Agent) => { + if (agent && agent.id && !map[agent.id]) { + map[agent.id] = agent; + } + }; + + const marketplaceData = queryClient.getQueriesData>([ + QueryKeys.marketplaceAgents, + ]); + marketplaceData.forEach(([_, data]) => { + data?.pages.forEach((page) => { + page.data.forEach(addToMap); + }); + }); + + const agentsListData = queryClient.getQueriesData([QueryKeys.agents]); + agentsListData.forEach(([_, data]) => { + if (data && Array.isArray(data.data)) { + data.data.forEach(addToMap); + } + }); + + agentQueries.forEach((query) => { + if (query.data) { + map[query.data.id] = query.data; + } + }); + + return map; + }, [agentQueries, queryClient]); + + const draggedFavoritesRef = useRef(safeFavorites); + + const moveItem = useCallback( + (dragIndex: number, hoverIndex: number) => { + const newFavorites = [...draggedFavoritesRef.current]; + const [draggedItem] = newFavorites.splice(dragIndex, 1); + newFavorites.splice(hoverIndex, 0, draggedItem); + draggedFavoritesRef.current = newFavorites; + reorderFavorites(newFavorites, false); + }, + [reorderFavorites], + ); + + const handleDrop = useCallback(() => { + // 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 = safeFavorites; + }, [safeFavorites]); + + if (search.query) { + return null; + } + + if (!isFavoritesLoading && safeFavorites.length === 0 && !showAgentMarketplace) { + return null; + } + + if (isFavoritesLoading) { + return ( +
+
+ {showAgentMarketplace && } + +
+
+ ); + } + + return ( +
+
+ {/* Show skeletons for ALL items while agents are still loading */} + {isAgentsLoading ? ( + <> + {/* Marketplace skeleton */} + {showAgentMarketplace && } + {/* Favorite items skeletons */} + {safeFavorites.map((_, index) => ( + + ))} + + ) : ( + <> + {/* Agent Marketplace button */} + {showAgentMarketplace && ( +
+
+
+ +
+ {localize('com_agents_marketplace')} +
+
+ )} + {safeFavorites.map((fav, index) => { + if (fav.agentId) { + const agent = agentsMap[fav.agentId]; + if (!agent) { + return null; + } + return ( + + + + ); + } else if (fav.model && fav.endpoint) { + return ( + + + + ); + } + return null; + })} + + )} +
+
+ ); +} diff --git a/client/src/components/Nav/Nav.tsx b/client/src/components/Nav/Nav.tsx index 68dfbe2481..60486f96c8 100644 --- a/client/src/components/Nav/Nav.tsx +++ b/client/src/components/Nav/Nav.tsx @@ -1,7 +1,8 @@ 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 { useMediaQuery } from '@librechat/client'; +import { Skeleton, useMediaQuery } from '@librechat/client'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; import type { ConversationListResponse } from 'librechat-data-provider'; import type { InfiniteQueryObserverResult } from '@tanstack/react-query'; @@ -21,11 +22,18 @@ import store from '~/store'; const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav')); const AccountSettings = lazy(() => import('./AccountSettings')); -const AgentMarketplaceButton = lazy(() => import('./AgentMarketplaceButton')); const NAV_WIDTH_DESKTOP = '260px'; const NAV_WIDTH_MOBILE = '320px'; +const SearchBarSkeleton = memo(() => ( +
+ +
+)); + +SearchBarSkeleton.displayName = 'SearchBarSkeleton'; + const NavMask = memo( ({ navVisible, toggleNavVisible }: { navVisible: boolean; toggleNavVisible: () => void }) => (
([]); @@ -92,7 +101,7 @@ const Nav = memo( }, [data?.pages]); const outerContainerRef = useRef(null); - const listRef = useRef(null); + const conversationsRef = useRef(null); const { moveToTop } = useNavScrolling({ setShowLoading, @@ -152,16 +161,18 @@ const Nav = memo( }, [isFetchingNextPage, computedHasNextPage, fetchNextPage]); const subHeaders = useMemo( - () => search.enabled === true && , + () => ( + <> + {search.enabled === null && } + {search.enabled === true && } + + ), [search.enabled, isSmallScreen], ); const headerButtons = useMemo( () => ( <> - - - {hasAccessToBookmarks && ( <>
@@ -172,7 +183,7 @@ const Nav = memo( )} ), - [hasAccessToBookmarks, tags, isSmallScreen, toggleNavVisible], + [hasAccessToBookmarks, tags], ); const [isSearchLoading, setIsSearchLoading] = useState( @@ -210,24 +221,28 @@ const Nav = memo(