From 1a83f36cdaab24bd376846734bba9084e84c8c1c Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:37:25 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=8C=20feat:=20Add=20Pin=20Support=20fo?= =?UTF-8?q?r=20Model=20Specs=20(#11219)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Enhance favorites functionality to support model specs * refactor(FavoritesList): reorder imports based on lenght * feat: improve favorite modelSpec controller; refactor: useIsActiveItem hook * refactor: consolidate Favorite type, harden controller, add tests - Add canonical TUserFavorite type in data-provider, replace three duplicate definitions (data-service, data-schemas, store/favorites) - Consolidate FavoritesController spec validation into single block, add return on 500 paths, add maxlength to mongoose sub-schema - Fix import order in FavoritesList.tsx, merge namespace type imports in FavoriteItem.tsx and FavoritesList.tsx - Add focus-visible:ring-inset on pin buttons to prevent ring clipping - Add explicit return type and JSDoc on useIsActiveItem hook - Use props.type for narrowing consistency in FavoriteItem getTypeLabel - Add 22 backend tests for FavoritesController (spec validation, typeCount exclusivity, persistence, GET path) - Add 40 frontend tests: useFavorites spec methods, useIsActiveItem observer lifecycle, ModelSpecItem pin button, FavoriteItem all three type branches, FavoritesList spec rendering * fix: address PR review findings for pin model specs - Harden backend validation to reject partial cross-type fields (e.g. spec+endpoint, agentId+model without endpoint) - Add stale-spec auto-cleanup in FavoritesList mirroring agent cleanup - Add type="button" to pin buttons in ModelSpecItem/EndpointModelItem - Fix import order violations in EndpointModelItem and ModelSpecItem - Remove hollow test, dead key prop, inline trivial helpers - Fix misleading test description, add onSelectSpec to test mock - Add return to controller success responses for consistency - Add 6 backend tests for partial cross-type field validation * fix: guard stale-spec cleanup against unloaded startupConfig Prevents race condition where spec favorites are incorrectly deleted on cold start before startupConfig has loaded. Mirrors the existing agentsMap === undefined guard pattern used for stale agent cleanup. Also adds tests for stale-spec cleanup persistence and fixes namespace import pattern in FavoritesList.spec.tsx. * fix: replace nested ternaries with if/else in FavoriteItem Resolves ESLint no-nested-ternary warnings for name and typeLabel derivations. --------- Co-authored-by: Danny Avila --- api/server/controllers/FavoritesController.js | 45 ++- .../controllers/FavoritesController.spec.js | 308 ++++++++++++++++++ .../Chat/Menus/Endpoints/CustomMenu.tsx | 3 +- .../components/EndpointModelItem.tsx | 34 +- .../Endpoints/components/ModelSpecItem.tsx | 41 ++- .../__tests__/EndpointModelItem.test.tsx | 1 + .../__tests__/ModelSpecItem.test.tsx | 125 +++++++ .../components/Nav/Favorites/FavoriteItem.tsx | 93 ++++-- .../Nav/Favorites/FavoritesList.tsx | 87 ++++- .../Nav/Favorites/tests/FavoriteItem.spec.tsx | 150 +++++++++ .../Favorites/tests/FavoritesList.spec.tsx | 143 +++++++- .../src/hooks/__tests__/useFavorites.spec.tsx | 186 +++++++++++ .../hooks/__tests__/useIsActiveItem.spec.tsx | 83 +++++ client/src/hooks/index.ts | 1 + client/src/hooks/useFavorites.ts | 60 +++- client/src/hooks/useIsActiveItem.ts | 33 ++ client/src/locales/en/translation.json | 1 + client/src/store/favorites.ts | 7 +- packages/data-provider/src/data-service.ts | 10 +- packages/data-provider/src/types/queries.ts | 12 + packages/data-schemas/src/schema/user.ts | 7 +- packages/data-schemas/src/types/user.ts | 7 +- 22 files changed, 1313 insertions(+), 124 deletions(-) create mode 100644 api/server/controllers/FavoritesController.spec.js create mode 100644 client/src/components/Chat/Menus/Endpoints/components/__tests__/ModelSpecItem.test.tsx create mode 100644 client/src/components/Nav/Favorites/tests/FavoriteItem.spec.tsx create mode 100644 client/src/hooks/__tests__/useFavorites.spec.tsx create mode 100644 client/src/hooks/__tests__/useIsActiveItem.spec.tsx create mode 100644 client/src/hooks/useIsActiveItem.ts diff --git a/api/server/controllers/FavoritesController.js b/api/server/controllers/FavoritesController.js index 186dd810bf..1dfe8e56cd 100644 --- a/api/server/controllers/FavoritesController.js +++ b/api/server/controllers/FavoritesController.js @@ -27,6 +27,7 @@ const updateFavoritesController = async (req, res) => { for (const fav of favorites) { const hasAgent = !!fav.agentId; const hasModel = !!(fav.model && fav.endpoint); + const hasSpec = !!fav.spec; if (fav.agentId && fav.agentId.length > MAX_STRING_LENGTH) { return res @@ -43,18 +44,46 @@ const updateFavoritesController = async (req, res) => { .status(400) .json({ message: `endpoint exceeds maximum length of ${MAX_STRING_LENGTH}` }); } + if (fav.spec !== undefined && fav.spec !== null) { + if (typeof fav.spec !== 'string' || fav.spec.length === 0) { + return res.status(400).json({ message: 'spec must be a non-empty string' }); + } + if (fav.spec.length > MAX_STRING_LENGTH) { + return res + .status(400) + .json({ message: `spec exceeds maximum length of ${MAX_STRING_LENGTH}` }); + } + } - if (!hasAgent && !hasModel) { + const hasPartialModel = !hasModel && !!(fav.model || fav.endpoint); + + if (hasPartialModel && !hasAgent && !hasSpec) { + return res.status(400).json({ message: 'model and endpoint must be provided together' }); + } + + const typeCount = [hasAgent, hasModel, hasSpec].filter(Boolean).length; + if (typeCount === 0) { return res.status(400).json({ - message: 'Each favorite must have either agentId or model+endpoint', + message: 'Each favorite must have either agentId, model+endpoint, or spec', }); } - if (hasAgent && hasModel) { + if (typeCount > 1) { return res.status(400).json({ - message: 'Favorite cannot have both agentId and model/endpoint', + message: 'Favorite cannot have multiple types (agentId, model/endpoint, or spec)', }); } + + if (hasSpec && (fav.agentId || fav.model || fav.endpoint)) { + return res + .status(400) + .json({ message: 'spec cannot be combined with agentId, model, or endpoint' }); + } + if (hasAgent && (fav.model || fav.endpoint)) { + return res + .status(400) + .json({ message: 'agentId cannot be combined with model or endpoint' }); + } } const user = await updateUser(userId, { favorites }); @@ -63,10 +92,10 @@ const updateFavoritesController = async (req, res) => { return res.status(404).json({ message: 'User not found' }); } - res.status(200).json(user.favorites); + return res.status(200).json(user.favorites); } catch (error) { console.error('Error updating favorites:', error); - res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: 'Internal server error' }); } }; @@ -86,10 +115,10 @@ const getFavoritesController = async (req, res) => { await updateUser(userId, { favorites: [] }); } - res.status(200).json(favorites); + return res.status(200).json(favorites); } catch (error) { console.error('Error fetching favorites:', error); - res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: 'Internal server error' }); } }; diff --git a/api/server/controllers/FavoritesController.spec.js b/api/server/controllers/FavoritesController.spec.js new file mode 100644 index 0000000000..c3aea3081d --- /dev/null +++ b/api/server/controllers/FavoritesController.spec.js @@ -0,0 +1,308 @@ +jest.mock('~/models', () => ({ + updateUser: jest.fn(), + getUserById: jest.fn(), +})); + +const { updateUser, getUserById } = require('~/models'); +const { updateFavoritesController, getFavoritesController } = require('./FavoritesController'); + +const makeRes = () => { + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +}; + +const makeReq = (body = {}) => ({ + body, + user: { id: 'user-123' }, +}); + +describe('FavoritesController', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('updateFavoritesController - payload envelope', () => { + it('rejects missing favorites key with 400', async () => { + const req = makeReq({}); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'Favorites data is required' }); + expect(updateUser).not.toHaveBeenCalled(); + }); + + it('rejects non-array favorites with 400', async () => { + const req = makeReq({ favorites: 'not-an-array' }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'Favorites must be an array' }); + }); + + it('rejects favorites over MAX_FAVORITES with 400 + code', async () => { + const favorites = Array.from({ length: 51 }, (_, i) => ({ agentId: `agent-${i}` })); + const req = makeReq({ favorites }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + code: 'MAX_FAVORITES_EXCEEDED', + message: 'Maximum 50 favorites allowed', + limit: 50, + }); + }); + }); + + describe('updateFavoritesController - agent/model length validation', () => { + it('rejects oversized agentId', async () => { + const req = makeReq({ favorites: [{ agentId: 'a'.repeat(257) }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'agentId exceeds maximum length of 256' }); + }); + + it('rejects oversized model', async () => { + const req = makeReq({ favorites: [{ model: 'm'.repeat(257), endpoint: 'openai' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'model exceeds maximum length of 256' }); + }); + }); + + describe('updateFavoritesController - spec validation', () => { + it('accepts a valid spec favorite', async () => { + updateUser.mockResolvedValue({ favorites: [{ spec: 'my-spec' }] }); + const req = makeReq({ favorites: [{ spec: 'my-spec' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith([{ spec: 'my-spec' }]); + expect(updateUser).toHaveBeenCalledWith('user-123', { + favorites: [{ spec: 'my-spec' }], + }); + }); + + it('rejects non-string spec with 400', async () => { + const req = makeReq({ favorites: [{ spec: 42 }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'spec must be a non-empty string' }); + }); + + it('rejects empty string spec with 400', async () => { + const req = makeReq({ favorites: [{ spec: '' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'spec must be a non-empty string' }); + }); + + it('rejects oversized spec with 400', async () => { + const req = makeReq({ favorites: [{ spec: 's'.repeat(257) }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'spec exceeds maximum length of 256' }); + }); + + it('allows undefined/null spec (treated as absent)', async () => { + updateUser.mockResolvedValue({ favorites: [{ agentId: 'a1' }] }); + const req = makeReq({ favorites: [{ agentId: 'a1', spec: null }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(200); + }); + }); + + describe('updateFavoritesController - exclusivity (typeCount)', () => { + it('rejects empty favorite entry with 400', async () => { + const req = makeReq({ favorites: [{}] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'Each favorite must have either agentId, model+endpoint, or spec', + }); + }); + + it('rejects agentId + model combination', async () => { + const req = makeReq({ + favorites: [{ agentId: 'a1', model: 'gpt-5', endpoint: 'openai' }], + }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'Favorite cannot have multiple types (agentId, model/endpoint, or spec)', + }); + }); + + it('rejects agentId + spec combination', async () => { + const req = makeReq({ favorites: [{ agentId: 'a1', spec: 's1' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'Favorite cannot have multiple types (agentId, model/endpoint, or spec)', + }); + }); + + it('rejects model + spec combination', async () => { + const req = makeReq({ + favorites: [{ model: 'gpt-5', endpoint: 'openai', spec: 's1' }], + }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('rejects spec with stray endpoint field', async () => { + const req = makeReq({ favorites: [{ spec: 's1', endpoint: 'openai' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'spec cannot be combined with agentId, model, or endpoint', + }); + }); + + it('rejects spec with stray model field', async () => { + const req = makeReq({ favorites: [{ spec: 's1', model: 'gpt-5' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'spec cannot be combined with agentId, model, or endpoint', + }); + }); + + it('rejects agentId with stray model field (no endpoint)', async () => { + const req = makeReq({ favorites: [{ agentId: 'a1', model: 'gpt-5' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'agentId cannot be combined with model or endpoint', + }); + }); + + it('rejects agentId with stray endpoint field (no model)', async () => { + const req = makeReq({ favorites: [{ agentId: 'a1', endpoint: 'openai' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'agentId cannot be combined with model or endpoint', + }); + }); + + it('rejects model without endpoint (partial model pair)', async () => { + const req = makeReq({ favorites: [{ model: 'gpt-5' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'model and endpoint must be provided together', + }); + }); + + it('rejects endpoint without model (partial model pair)', async () => { + const req = makeReq({ favorites: [{ endpoint: 'openai' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'model and endpoint must be provided together', + }); + }); + + it('accepts a mixed array of valid single-type favorites', async () => { + const favorites = [{ agentId: 'a1' }, { model: 'gpt-5', endpoint: 'openai' }, { spec: 's1' }]; + updateUser.mockResolvedValue({ favorites }); + const req = makeReq({ favorites }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(favorites); + }); + }); + + describe('updateFavoritesController - persistence', () => { + it('returns 404 when user is not found', async () => { + updateUser.mockResolvedValue(null); + const req = makeReq({ favorites: [{ spec: 's1' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ message: 'User not found' }); + }); + + it('returns 500 when updateUser throws', async () => { + updateUser.mockRejectedValue(new Error('db down')); + const req = makeReq({ favorites: [{ spec: 's1' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ message: 'Internal server error' }); + }); + }); + + describe('getFavoritesController', () => { + it('returns the user favorites array', async () => { + const favorites = [{ agentId: 'a1' }, { spec: 's1' }]; + getUserById.mockResolvedValue({ favorites }); + const req = makeReq(); + const res = makeRes(); + await getFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(favorites); + }); + + it('returns [] when user.favorites is null (falsy)', async () => { + getUserById.mockResolvedValue({ favorites: null }); + const req = makeReq(); + const res = makeRes(); + await getFavoritesController(req, res); + expect(updateUser).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith([]); + }); + + it('repairs corrupt favorites field (non-array truthy)', async () => { + getUserById.mockResolvedValue({ favorites: 'corrupt' }); + updateUser.mockResolvedValue({ favorites: [] }); + const req = makeReq(); + const res = makeRes(); + await getFavoritesController(req, res); + expect(updateUser).toHaveBeenCalledWith('user-123', { favorites: [] }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith([]); + }); + + it('returns 404 when user not found', async () => { + getUserById.mockResolvedValue(null); + const req = makeReq(); + const res = makeRes(); + await getFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(404); + }); + + it('returns 500 when getUserById throws', async () => { + getUserById.mockRejectedValue(new Error('db down')); + const req = makeReq(); + const res = makeRes(); + await getFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(500); + }); + }); +}); diff --git a/client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx b/client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx index 14ce5bb209..d72fedd21d 100644 --- a/client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx +++ b/client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx @@ -67,7 +67,8 @@ export const CustomMenu = React.forwardRef(func parent ? 'animate-popover-left ml-3' : 'animate-popover', 'outline-none! z-40 flex max-h-[min(450px,var(--popover-available-height))] w-full', 'w-[var(--menu-width,auto)] min-w-[300px] flex-col overflow-auto rounded-xl border border-border-light', - 'bg-presentation px-3 py-2 text-sm text-text-primary shadow-lg', + 'bg-presentation text-sm text-text-primary shadow-lg', + parent ? 'px-0.5 py-0.5' : 'px-3 py-2', 'max-w-[calc(100vw-4rem)] sm:max-h-[calc(65vh)] sm:max-w-[400px]', searchable && 'p-0', )} diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx index 7cec4744d5..3c3e58fc9e 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx @@ -1,11 +1,11 @@ -import React, { useRef, useState, useEffect } from 'react'; +import React from 'react'; import { VisuallyHidden } from '@ariakit/react'; import { CheckCircle2, EarthIcon, Pin, PinOff } from 'lucide-react'; import { isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider'; +import type { Endpoint } from '~/common'; +import { useFavorites, useLocalize, useIsActiveItem } from '~/hooks'; 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 { @@ -26,24 +26,7 @@ export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps) const { isFavoriteModel, toggleFavoriteModel, isFavoriteAgent, toggleFavoriteAgent } = useFavorites(); - const itemRef = useRef(null); - const [isActive, setIsActive] = useState(false); - - useEffect(() => { - const element = itemRef.current; - if (!element) { - return; - } - - const observer = new MutationObserver(() => { - setIsActive(element.hasAttribute('data-active-item')); - }); - - observer.observe(element, { attributes: true, attributeFilter: ['data-active-item'] }); - setIsActive(element.hasAttribute('data-active-item')); - - return () => observer.disconnect(); - }, []); + const { ref: itemRef, isActive } = useIsActiveItem(); let isGlobal = false; let modelName = modelId; @@ -126,16 +109,19 @@ export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps) {isGlobal && } {isSelected && ( <> ({ isFavoriteAgent: () => false, toggleFavoriteAgent: jest.fn(), }), + useIsActiveItem: () => ({ ref: { current: null }, isActive: false }), })); const baseEndpoint: Endpoint = { diff --git a/client/src/components/Chat/Menus/Endpoints/components/__tests__/ModelSpecItem.test.tsx b/client/src/components/Chat/Menus/Endpoints/components/__tests__/ModelSpecItem.test.tsx new file mode 100644 index 0000000000..fe863e9a3b --- /dev/null +++ b/client/src/components/Chat/Menus/Endpoints/components/__tests__/ModelSpecItem.test.tsx @@ -0,0 +1,125 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import type { TModelSpec } from 'librechat-data-provider'; +import { ModelSpecItem } from '../ModelSpecItem'; + +const mockHandleSelectSpec = jest.fn(); +const mockToggleFavoriteSpec = jest.fn(); +let mockIsFavoriteSpec = false; +let mockIsActive = false; + +jest.mock('~/components/Chat/Menus/Endpoints/ModelSelectorContext', () => ({ + useModelSelectorContext: () => ({ + handleSelectSpec: mockHandleSelectSpec, + endpointsConfig: {}, + }), +})); + +jest.mock('~/components/Chat/Menus/Endpoints/CustomMenu', () => { + const React = jest.requireActual('react'); + return { + CustomMenuItem: React.forwardRef(function MockMenuItem( + { children, ...rest }: { children?: React.ReactNode }, + ref: React.Ref, + ) { + return React.createElement('div', { ref, role: 'menuitem', ...rest }, children); + }), + }; +}); + +jest.mock('../SpecIcon', () => ({ + __esModule: true, + default: () => , +})); + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, + useFavorites: () => ({ + isFavoriteSpec: () => mockIsFavoriteSpec, + toggleFavoriteSpec: mockToggleFavoriteSpec, + }), + useIsActiveItem: () => ({ ref: { current: null }, isActive: mockIsActive }), +})); + +const baseSpec: TModelSpec = { + name: 'my-spec', + label: 'My Spec', + preset: { + endpoint: 'openai', + model: 'gpt-5', + }, +}; + +describe('ModelSpecItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsFavoriteSpec = false; + mockIsActive = false; + }); + + it('renders the spec label and icon', () => { + render(); + expect(screen.getByText('My Spec')).toBeInTheDocument(); + expect(screen.getByTestId('spec-icon')).toBeInTheDocument(); + }); + + it('renders description when provided', () => { + render( + , + ); + expect(screen.getByText('Fast and cheap')).toBeInTheDocument(); + }); + + it('renders aria-selected=true when isSelected', () => { + render(); + expect(screen.getByRole('menuitem')).toHaveAttribute('aria-selected', 'true'); + }); + + it('does NOT set aria-selected when not selected', () => { + render(); + expect(screen.getByRole('menuitem')).not.toHaveAttribute('aria-selected'); + }); + + it('calls handleSelectSpec on row click', () => { + render(); + fireEvent.click(screen.getByRole('menuitem')); + expect(mockHandleSelectSpec).toHaveBeenCalledWith(baseSpec); + }); + + describe('pin button', () => { + it('renders Pin icon with "com_ui_pin" label when not favorited', () => { + mockIsFavoriteSpec = false; + render(); + expect(screen.getByRole('button', { name: 'com_ui_pin' })).toBeInTheDocument(); + }); + + it('renders PinOff icon with "com_ui_unpin" label when favorited', () => { + mockIsFavoriteSpec = true; + render(); + expect(screen.getByRole('button', { name: 'com_ui_unpin' })).toBeInTheDocument(); + }); + + it('calls toggleFavoriteSpec with spec.name on click', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_pin' })); + expect(mockToggleFavoriteSpec).toHaveBeenCalledWith('my-spec'); + }); + + it('stops propagation so handleSelectSpec is not fired', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_pin' })); + expect(mockHandleSelectSpec).not.toHaveBeenCalled(); + }); + + it('has tabIndex=-1 when item is not active', () => { + mockIsActive = false; + render(); + expect(screen.getByRole('button', { name: 'com_ui_pin' })).toHaveAttribute('tabindex', '-1'); + }); + + it('has tabIndex=0 when item is active', () => { + mockIsActive = true; + render(); + expect(screen.getByRole('button', { name: 'com_ui_pin' })).toHaveAttribute('tabindex', '0'); + }); + }); +}); diff --git a/client/src/components/Nav/Favorites/FavoriteItem.tsx b/client/src/components/Nav/Favorites/FavoriteItem.tsx index 248008869d..f373d26e93 100644 --- a/client/src/components/Nav/Favorites/FavoriteItem.tsx +++ b/client/src/components/Nav/Favorites/FavoriteItem.tsx @@ -3,8 +3,9 @@ import * as Menu from '@ariakit/react/menu'; import { Ellipsis, PinOff } from 'lucide-react'; import { DropdownPopup } from '@librechat/client'; import { EModelEndpoint } from 'librechat-data-provider'; +import type { Agent, TModelSpec, TEndpointsConfig } from 'librechat-data-provider'; import type { FavoriteModel } from '~/store/favorites'; -import type t from 'librechat-data-provider'; +import SpecIcon from '~/components/Chat/Menus/Endpoints/components/SpecIcon'; import MinimalIcon from '~/components/Endpoints/MinimalIcon'; import { useFavorites, useLocalize } from '~/hooks'; import { renderAgentAvatar, cn } from '~/utils'; @@ -16,30 +17,44 @@ type Kwargs = { spec?: string | null; }; -type FavoriteItemProps = { - item: t.Agent | FavoriteModel; - type: 'agent' | 'model'; - onSelectEndpoint?: (endpoint?: EModelEndpoint | string | null, kwargs?: Kwargs) => void; +type FavoriteItemBaseProps = { onRemoveFocus?: () => void; }; -export default function FavoriteItem({ - item, - type, - onSelectEndpoint, - onRemoveFocus, -}: FavoriteItemProps) { +type AgentFavoriteProps = FavoriteItemBaseProps & { + type: 'agent'; + item: Agent; + onSelectEndpoint?: (endpoint?: EModelEndpoint | string | null, kwargs?: Kwargs) => void; +}; + +type ModelFavoriteProps = FavoriteItemBaseProps & { + type: 'model'; + item: FavoriteModel; + onSelectEndpoint?: (endpoint?: EModelEndpoint | string | null, kwargs?: Kwargs) => void; +}; + +type SpecFavoriteProps = FavoriteItemBaseProps & { + type: 'spec'; + item: TModelSpec; + onSelectSpec?: (spec: TModelSpec) => void; + endpointsConfig?: TEndpointsConfig; +}; + +type FavoriteItemProps = AgentFavoriteProps | ModelFavoriteProps | SpecFavoriteProps; + +export default function FavoriteItem(props: FavoriteItemProps) { + const { onRemoveFocus } = props; const localize = useLocalize(); - const { removeFavoriteAgent, removeFavoriteModel } = useFavorites(); + const { removeFavoriteAgent, removeFavoriteModel, removeFavoriteSpec } = useFavorites(); const [isPopoverActive, setIsPopoverActive] = useState(false); const handleSelect = () => { - if (type === 'agent') { - const agent = item as t.Agent; - onSelectEndpoint?.(EModelEndpoint.agents, { agent_id: agent.id }); + if (props.type === 'agent') { + props.onSelectEndpoint?.(EModelEndpoint.agents, { agent_id: props.item.id }); + } else if (props.type === 'spec') { + props.onSelectSpec?.(props.item); } else { - const model = item as FavoriteModel; - onSelectEndpoint?.(model.endpoint, { model: model.model }); + props.onSelectEndpoint?.(props.item.endpoint, { model: props.item.model }); } }; @@ -59,11 +74,12 @@ export default function FavoriteItem({ const handleRemove = (e: React.MouseEvent) => { e.stopPropagation(); - if (type === 'agent') { - removeFavoriteAgent((item as t.Agent).id); + if (props.type === 'agent') { + removeFavoriteAgent(props.item.id); + } else if (props.type === 'spec') { + removeFavoriteSpec(props.item.name); } else { - const model = item as FavoriteModel; - removeFavoriteModel(model.model, model.endpoint); + removeFavoriteModel(props.item.model, props.item.endpoint); } setIsPopoverActive(false); requestAnimationFrame(() => { @@ -72,26 +88,35 @@ export default function FavoriteItem({ }; const renderIcon = () => { - if (type === 'agent') { - return renderAgentAvatar(item as t.Agent, { size: 'icon', className: 'mr-2' }); + if (props.type === 'agent') { + return renderAgentAvatar(props.item, { size: 'icon', className: 'mr-2' }); + } + if (props.type === 'spec') { + return ( +
+ +
+ ); } - const model = item as FavoriteModel; return (
- +
); }; - const getName = (): string => { - if (type === 'agent') { - return (item as t.Agent).name ?? ''; - } - return (item as FavoriteModel).model; - }; - - const name = getName(); - const typeLabel = type === 'agent' ? localize('com_ui_agent') : localize('com_ui_model'); + let name: string; + let typeLabel: string; + if (props.type === 'agent') { + name = props.item.name ?? ''; + typeLabel = localize('com_ui_agent'); + } else if (props.type === 'spec') { + name = props.item.label; + typeLabel = localize('com_ui_model_spec'); + } else { + name = props.item.model; + typeLabel = localize('com_ui_model'); + } const ariaLabel = `${name} (${typeLabel})`; const menuId = React.useId(); diff --git a/client/src/components/Nav/Favorites/FavoritesList.tsx b/client/src/components/Nav/Favorites/FavoritesList.tsx index 934652349f..d36fb2de71 100644 --- a/client/src/components/Nav/Favorites/FavoritesList.tsx +++ b/client/src/components/Nav/Favorites/FavoritesList.tsx @@ -1,23 +1,23 @@ import React, { useRef, useCallback, useMemo, useEffect } from 'react'; import { LayoutGrid } from 'lucide-react'; +import { useRecoilValue } from 'recoil'; import { useDrag, useDrop } from 'react-dnd'; import { Skeleton } from '@librechat/client'; import { useNavigate } from 'react-router-dom'; import { useQueries } from '@tanstack/react-query'; -import { useRecoilValue } from 'recoil'; import { QueryKeys, dataService } from 'librechat-data-provider'; -import type t from 'librechat-data-provider'; +import type { Agent, TEndpointsConfig, TModelSpec } from 'librechat-data-provider'; import type { AgentQueryResult } from '~/common'; import { useGetConversation, - useShowMarketplace, useFavorites, useLocalize, + useShowMarketplace, useNewConvo, } from '~/hooks'; +import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider'; import { useAssistantsMapContext, useAgentsMapContext } from '~/Providers'; import useSelectMention from '~/hooks/Input/useSelectMention'; -import { useGetEndpointsQuery } from '~/data-provider'; import FavoriteItem from './FavoriteItem'; import store from '~/store'; @@ -133,10 +133,24 @@ export default function FavoritesList({ const { newConversation } = useNewConvo(); const assistantsMap = useAssistantsMapContext(); const agentsMap = useAgentsMapContext(); - const { data: endpointsConfig = {} as t.TEndpointsConfig } = useGetEndpointsQuery(); + const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery(); + const { data: startupConfig } = useGetStartupConfig(); - const { onSelectEndpoint: _onSelectEndpoint } = useSelectMention({ - modelSpecs: [], + const modelSpecs = useMemo( + () => startupConfig?.modelSpecs?.list ?? [], + [startupConfig?.modelSpecs?.list], + ); + + const specsMap = useMemo(() => { + const map: Record = {}; + for (const spec of modelSpecs) { + map[spec.name] = spec; + } + return map; + }, [modelSpecs]); + + const { onSelectEndpoint: _onSelectEndpoint, onSelectSpec: _onSelectSpec } = useSelectMention({ + modelSpecs, assistantsMap, endpointsConfig, getConversation, @@ -154,6 +168,16 @@ export default function FavoritesList({ [_onSelectEndpoint, isSmallScreen, toggleNav], ); + const onSelectSpec = useCallback( + (...args: Parameters>) => { + _onSelectSpec?.(...args); + if (isSmallScreen && toggleNav) { + toggleNav(); + } + }, + [_onSelectSpec, isSmallScreen, toggleNav], + ); + const marketplaceRef = useRef(null); const listContainerRef = useRef(null); @@ -243,11 +267,36 @@ export default function FavoritesList({ } }, [staleAgentIdsKey, safeFavorites, reorderFavorites]); + const staleSpecNamesKey = useMemo(() => { + if (startupConfig === undefined) { + return ''; + } + return safeFavorites + .filter((f) => f.spec && !specsMap[f.spec]) + .map((f) => f.spec as string) + .sort() + .join(','); + }, [safeFavorites, specsMap, startupConfig]); + + const specCleanupAttemptedRef = useRef(''); + + useEffect(() => { + if (!staleSpecNamesKey || specCleanupAttemptedRef.current === staleSpecNamesKey) { + return; + } + const staleSet = new Set(staleSpecNamesKey.split(',')); + const cleaned = safeFavorites.filter((f) => !f.spec || !staleSet.has(f.spec)); + if (cleaned.length < safeFavorites.length) { + specCleanupAttemptedRef.current = staleSpecNamesKey; + reorderFavorites(cleaned, true); + } + }, [staleSpecNamesKey, safeFavorites, reorderFavorites]); + const combinedAgentsMap = useMemo(() => { if (agentsMap === undefined) { return undefined; } - const combined: Record = {}; + const combined: Record = {}; for (const [key, value] of Object.entries(agentsMap)) { if (value) { combined[key] = value; @@ -369,6 +418,28 @@ export default function FavoritesList({ /> ); + } else if (fav.spec) { + const spec = specsMap[fav.spec]; + if (!spec) { + return null; + } + return ( + + + + ); } else if (fav.model && fav.endpoint) { return ( ({ + useLocalize: () => (key: string) => key, + useFavorites: () => ({ + removeFavoriteAgent: mockRemoveFavoriteAgent, + removeFavoriteModel: mockRemoveFavoriteModel, + removeFavoriteSpec: mockRemoveFavoriteSpec, + }), +})); + +jest.mock('~/components/Chat/Menus/Endpoints/components/SpecIcon', () => ({ + __esModule: true, + default: () => , +})); + +jest.mock('~/components/Endpoints/MinimalIcon', () => ({ + __esModule: true, + default: () => , +})); + +jest.mock('~/utils', () => ({ + ...jest.requireActual('~/utils'), + renderAgentAvatar: () => , +})); + +jest.mock('@librechat/client', () => ({ + ...jest.requireActual('@librechat/client'), + DropdownPopup: () =>
, +})); + +jest.mock('@ariakit/react/menu', () => ({ + MenuButton: ({ children }: { children?: React.ReactNode }) => ( + + ), +})); + +const baseAgent: Agent = { + id: 'agent-123', + name: 'Research Agent', + avatar: null, + provider: 'openai', + model: 'gpt-5', + instructions: '', + description: '', + tools: [], + created_at: 0, + updated_at: 0, + author: 'u1', +} as unknown as Agent; + +const baseModel: FavoriteModel = { model: 'gpt-5', endpoint: 'openai' }; + +const baseSpec: TModelSpec = { + name: 'my-spec', + label: 'My Model Spec', + preset: { endpoint: 'openai', model: 'gpt-5' }, +}; + +describe('FavoriteItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('type="agent"', () => { + it('renders the agent name and avatar', () => { + const onSelectEndpoint = jest.fn(); + render(); + expect(screen.getByText('Research Agent')).toBeInTheDocument(); + expect(screen.getByTestId('agent-avatar')).toBeInTheDocument(); + }); + + it('has aria-label formatted as " (com_ui_agent)"', () => { + render(); + expect( + screen.getByRole('button', { name: 'Research Agent (com_ui_agent)' }), + ).toBeInTheDocument(); + }); + + it('calls onSelectEndpoint with agents endpoint + agent_id on click', () => { + const onSelectEndpoint = jest.fn(); + render(); + fireEvent.click(screen.getByTestId('favorite-item')); + expect(onSelectEndpoint).toHaveBeenCalledWith('agents', { agent_id: 'agent-123' }); + }); + }); + + describe('type="model"', () => { + it('renders model name and minimal icon', () => { + render(); + expect(screen.getByText('gpt-5')).toBeInTheDocument(); + expect(screen.getByTestId('minimal-icon')).toBeInTheDocument(); + }); + + it('has aria-label formatted as " (com_ui_model)"', () => { + render(); + expect(screen.getByRole('button', { name: 'gpt-5 (com_ui_model)' })).toBeInTheDocument(); + }); + + it('calls onSelectEndpoint with endpoint + model on click', () => { + const onSelectEndpoint = jest.fn(); + render(); + fireEvent.click(screen.getByTestId('favorite-item')); + expect(onSelectEndpoint).toHaveBeenCalledWith('openai', { model: 'gpt-5' }); + }); + }); + + describe('type="spec"', () => { + it('renders the spec label and SpecIcon', () => { + render(); + expect(screen.getByText('My Model Spec')).toBeInTheDocument(); + expect(screen.getByTestId('spec-icon')).toBeInTheDocument(); + }); + + it('has aria-label formatted as "