📌 feat: Add Pin Support for Model Specs (#11219)

* 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 <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2026-04-10 00:37:25 +02:00 committed by GitHub
parent daa8f0ea6b
commit 1a83f36cda
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1313 additions and 124 deletions

View file

@ -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' });
}
};

View file

@ -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);
});
});
});

View file

@ -67,7 +67,8 @@ export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(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',
)}

View file

@ -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<HTMLDivElement>(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<HTMLDivElement>();
let isGlobal = false;
let modelName = modelId;
@ -126,16 +109,19 @@ export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps)
{isGlobal && <EarthIcon className="ml-1 size-4 text-surface-submit" />}
</div>
<button
type="button"
tabIndex={isActive ? 0 : -1}
onClick={handleFavoriteClick}
aria-label={isFavorite ? localize('com_ui_unpin') : localize('com_ui_pin')}
className={cn(
'rounded-md p-1 hover:bg-surface-hover',
isFavorite ? 'visible' : 'invisible group-hover:visible group-data-[active-item]:visible',
'rounded-md p-1 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring-primary',
isFavorite
? 'visible'
: 'invisible group-focus-within:visible group-hover:visible group-data-[active-item]:visible',
)}
>
{isFavorite ? (
<PinOff className="h-4 w-4 text-text-secondary" />
<PinOff className="h-4 w-4 text-text-secondary" aria-hidden="true" />
) : (
<Pin className="h-4 w-4 text-text-secondary" aria-hidden="true" />
)}

View file

@ -1,10 +1,10 @@
import React from 'react';
import { CheckCircle2 } from 'lucide-react';
import { VisuallyHidden } from '@ariakit/react';
import { CheckCircle2, Pin, PinOff } from 'lucide-react';
import type { TModelSpec } from 'librechat-data-provider';
import { CustomMenuItem as MenuItem } from '../CustomMenu';
import { useFavorites, useLocalize, useIsActiveItem } from '~/hooks';
import { useModelSelectorContext } from '../ModelSelectorContext';
import { useLocalize } from '~/hooks';
import { CustomMenuItem as MenuItem } from '../CustomMenu';
import SpecIcon from './SpecIcon';
import { cn } from '~/utils';
@ -16,15 +16,24 @@ interface ModelSpecItemProps {
export function ModelSpecItem({ spec, isSelected }: ModelSpecItemProps) {
const localize = useLocalize();
const { handleSelectSpec, endpointsConfig } = useModelSelectorContext();
const { isFavoriteSpec, toggleFavoriteSpec } = useFavorites();
const { showIconInMenu = true } = spec;
const { ref: itemRef, isActive } = useIsActiveItem<HTMLDivElement>();
const isFavorite = isFavoriteSpec(spec.name);
const handleFavoriteClick = (e: React.MouseEvent) => {
e.stopPropagation();
toggleFavoriteSpec(spec.name);
};
return (
<MenuItem
key={spec.name}
ref={itemRef}
onClick={() => handleSelectSpec(spec)}
aria-selected={isSelected || undefined}
className={cn(
'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"
>
<div
className={cn(
@ -44,6 +53,24 @@ export function ModelSpecItem({ spec, isSelected }: ModelSpecItemProps) {
)}
</div>
</div>
<button
type="button"
tabIndex={isActive ? 0 : -1}
onClick={handleFavoriteClick}
aria-label={isFavorite ? localize('com_ui_unpin') : localize('com_ui_pin')}
className={cn(
'rounded-md p-1 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring-primary',
isFavorite
? 'visible'
: 'invisible group-focus-within:visible group-hover:visible group-data-[active-item]:visible',
)}
>
{isFavorite ? (
<PinOff className="h-4 w-4 text-text-secondary" aria-hidden="true" />
) : (
<Pin className="h-4 w-4 text-text-secondary" aria-hidden="true" />
)}
</button>
{isSelected && (
<>
<CheckCircle2

View file

@ -32,6 +32,7 @@ jest.mock('~/hooks', () => ({
isFavoriteAgent: () => false,
toggleFavoriteAgent: jest.fn(),
}),
useIsActiveItem: () => ({ ref: { current: null }, isActive: false }),
}));
const baseEndpoint: Endpoint = {

View file

@ -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<typeof import('react')>('react');
return {
CustomMenuItem: React.forwardRef(function MockMenuItem(
{ children, ...rest }: { children?: React.ReactNode },
ref: React.Ref<HTMLDivElement>,
) {
return React.createElement('div', { ref, role: 'menuitem', ...rest }, children);
}),
};
});
jest.mock('../SpecIcon', () => ({
__esModule: true,
default: () => <span data-testid="spec-icon" />,
}));
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(<ModelSpecItem spec={baseSpec} isSelected={false} />);
expect(screen.getByText('My Spec')).toBeInTheDocument();
expect(screen.getByTestId('spec-icon')).toBeInTheDocument();
});
it('renders description when provided', () => {
render(
<ModelSpecItem spec={{ ...baseSpec, description: 'Fast and cheap' }} isSelected={false} />,
);
expect(screen.getByText('Fast and cheap')).toBeInTheDocument();
});
it('renders aria-selected=true when isSelected', () => {
render(<ModelSpecItem spec={baseSpec} isSelected={true} />);
expect(screen.getByRole('menuitem')).toHaveAttribute('aria-selected', 'true');
});
it('does NOT set aria-selected when not selected', () => {
render(<ModelSpecItem spec={baseSpec} isSelected={false} />);
expect(screen.getByRole('menuitem')).not.toHaveAttribute('aria-selected');
});
it('calls handleSelectSpec on row click', () => {
render(<ModelSpecItem spec={baseSpec} isSelected={false} />);
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(<ModelSpecItem spec={baseSpec} isSelected={false} />);
expect(screen.getByRole('button', { name: 'com_ui_pin' })).toBeInTheDocument();
});
it('renders PinOff icon with "com_ui_unpin" label when favorited', () => {
mockIsFavoriteSpec = true;
render(<ModelSpecItem spec={baseSpec} isSelected={false} />);
expect(screen.getByRole('button', { name: 'com_ui_unpin' })).toBeInTheDocument();
});
it('calls toggleFavoriteSpec with spec.name on click', () => {
render(<ModelSpecItem spec={baseSpec} isSelected={false} />);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_pin' }));
expect(mockToggleFavoriteSpec).toHaveBeenCalledWith('my-spec');
});
it('stops propagation so handleSelectSpec is not fired', () => {
render(<ModelSpecItem spec={baseSpec} isSelected={false} />);
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(<ModelSpecItem spec={baseSpec} isSelected={false} />);
expect(screen.getByRole('button', { name: 'com_ui_pin' })).toHaveAttribute('tabindex', '-1');
});
it('has tabIndex=0 when item is active', () => {
mockIsActive = true;
render(<ModelSpecItem spec={baseSpec} isSelected={false} />);
expect(screen.getByRole('button', { name: 'com_ui_pin' })).toHaveAttribute('tabindex', '0');
});
});
});

View file

@ -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 (
<div className="mr-2 h-5 w-5">
<SpecIcon currentSpec={props.item} endpointsConfig={props.endpointsConfig} />
</div>
);
}
const model = item as FavoriteModel;
return (
<div className="mr-2 h-5 w-5">
<MinimalIcon endpoint={model.endpoint} size={20} isCreatedByUser={false} />
<MinimalIcon endpoint={props.item.endpoint} size={20} isCreatedByUser={false} />
</div>
);
};
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();

View file

@ -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<string, TModelSpec> = {};
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<NonNullable<typeof _onSelectSpec>>) => {
_onSelectSpec?.(...args);
if (isSmallScreen && toggleNav) {
toggleNav();
}
},
[_onSelectSpec, isSmallScreen, toggleNav],
);
const marketplaceRef = useRef<HTMLDivElement>(null);
const listContainerRef = useRef<HTMLDivElement>(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<string, t.Agent> = {};
const combined: Record<string, Agent> = {};
for (const [key, value] of Object.entries(agentsMap)) {
if (value) {
combined[key] = value;
@ -369,6 +418,28 @@ export default function FavoritesList({
/>
</DraggableFavoriteItem>
);
} else if (fav.spec) {
const spec = specsMap[fav.spec];
if (!spec) {
return null;
}
return (
<DraggableFavoriteItem
key={`spec-${fav.spec}`}
id={`spec-${fav.spec}`}
index={index}
moveItem={moveItem}
onDrop={handleDrop}
>
<FavoriteItem
item={spec}
type="spec"
onSelectSpec={onSelectSpec}
onRemoveFocus={handleRemoveFocus}
endpointsConfig={endpointsConfig}
/>
</DraggableFavoriteItem>
);
} else if (fav.model && fav.endpoint) {
return (
<DraggableFavoriteItem

View file

@ -0,0 +1,150 @@
import { render, screen, fireEvent } from '@testing-library/react';
import type { Agent, TModelSpec } from 'librechat-data-provider';
import type { FavoriteModel } from '~/store/favorites';
import FavoriteItem from '../FavoriteItem';
const mockRemoveFavoriteAgent = jest.fn();
const mockRemoveFavoriteModel = jest.fn();
const mockRemoveFavoriteSpec = jest.fn();
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
useFavorites: () => ({
removeFavoriteAgent: mockRemoveFavoriteAgent,
removeFavoriteModel: mockRemoveFavoriteModel,
removeFavoriteSpec: mockRemoveFavoriteSpec,
}),
}));
jest.mock('~/components/Chat/Menus/Endpoints/components/SpecIcon', () => ({
__esModule: true,
default: () => <span data-testid="spec-icon" />,
}));
jest.mock('~/components/Endpoints/MinimalIcon', () => ({
__esModule: true,
default: () => <span data-testid="minimal-icon" />,
}));
jest.mock('~/utils', () => ({
...jest.requireActual('~/utils'),
renderAgentAvatar: () => <span data-testid="agent-avatar" />,
}));
jest.mock('@librechat/client', () => ({
...jest.requireActual('@librechat/client'),
DropdownPopup: () => <div data-testid="dropdown-popup" />,
}));
jest.mock('@ariakit/react/menu', () => ({
MenuButton: ({ children }: { children?: React.ReactNode }) => (
<button type="button">{children}</button>
),
}));
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(<FavoriteItem type="agent" item={baseAgent} onSelectEndpoint={onSelectEndpoint} />);
expect(screen.getByText('Research Agent')).toBeInTheDocument();
expect(screen.getByTestId('agent-avatar')).toBeInTheDocument();
});
it('has aria-label formatted as "<name> (com_ui_agent)"', () => {
render(<FavoriteItem type="agent" item={baseAgent} />);
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(<FavoriteItem type="agent" item={baseAgent} onSelectEndpoint={onSelectEndpoint} />);
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(<FavoriteItem type="model" item={baseModel} />);
expect(screen.getByText('gpt-5')).toBeInTheDocument();
expect(screen.getByTestId('minimal-icon')).toBeInTheDocument();
});
it('has aria-label formatted as "<model> (com_ui_model)"', () => {
render(<FavoriteItem type="model" item={baseModel} />);
expect(screen.getByRole('button', { name: 'gpt-5 (com_ui_model)' })).toBeInTheDocument();
});
it('calls onSelectEndpoint with endpoint + model on click', () => {
const onSelectEndpoint = jest.fn();
render(<FavoriteItem type="model" item={baseModel} onSelectEndpoint={onSelectEndpoint} />);
fireEvent.click(screen.getByTestId('favorite-item'));
expect(onSelectEndpoint).toHaveBeenCalledWith('openai', { model: 'gpt-5' });
});
});
describe('type="spec"', () => {
it('renders the spec label and SpecIcon', () => {
render(<FavoriteItem type="spec" item={baseSpec} />);
expect(screen.getByText('My Model Spec')).toBeInTheDocument();
expect(screen.getByTestId('spec-icon')).toBeInTheDocument();
});
it('has aria-label formatted as "<label> (com_ui_model_spec)"', () => {
render(<FavoriteItem type="spec" item={baseSpec} />);
expect(
screen.getByRole('button', { name: 'My Model Spec (com_ui_model_spec)' }),
).toBeInTheDocument();
});
it('calls onSelectSpec with the full spec on click', () => {
const onSelectSpec = jest.fn();
render(<FavoriteItem type="spec" item={baseSpec} onSelectSpec={onSelectSpec} />);
fireEvent.click(screen.getByTestId('favorite-item'));
expect(onSelectSpec).toHaveBeenCalledWith(baseSpec);
});
it('activates on Enter key', () => {
const onSelectSpec = jest.fn();
render(<FavoriteItem type="spec" item={baseSpec} onSelectSpec={onSelectSpec} />);
fireEvent.keyDown(screen.getByTestId('favorite-item'), { key: 'Enter' });
expect(onSelectSpec).toHaveBeenCalledWith(baseSpec);
});
it('activates on Space key', () => {
const onSelectSpec = jest.fn();
render(<FavoriteItem type="spec" item={baseSpec} onSelectSpec={onSelectSpec} />);
fireEvent.keyDown(screen.getByTestId('favorite-item'), { key: ' ' });
expect(onSelectSpec).toHaveBeenCalledWith(baseSpec);
});
});
});

View file

@ -7,7 +7,7 @@ import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { BrowserRouter } from 'react-router-dom';
import { dataService } from 'librechat-data-provider';
import type t from 'librechat-data-provider';
import type { Agent } from 'librechat-data-provider';
// Mock store before importing FavoritesList
jest.mock('~/store', () => {
@ -33,6 +33,7 @@ type FavoriteItem = {
agentId?: string;
model?: string;
endpoint?: string;
spec?: string;
};
// Mock dataService
@ -64,21 +65,36 @@ jest.mock('~/Providers', () => ({
useAgentsMapContext: () => ({}),
}));
const mockOnSelectSpec = jest.fn();
jest.mock('~/hooks/Input/useSelectMention', () => () => ({
onSelectEndpoint: jest.fn(),
onSelectSpec: mockOnSelectSpec,
}));
const mockUseGetStartupConfig = jest.fn(() => ({
data: { modelSpecs: { list: [] as unknown[] } },
}));
jest.mock('~/data-provider', () => ({
useGetEndpointsQuery: () => ({ data: {} }),
useGetStartupConfig: () => mockUseGetStartupConfig(),
}));
jest.mock('../FavoriteItem', () => ({
__esModule: true,
default: ({ item, type }: { item: any; type: string }) => (
<div data-testid="favorite-item" data-type={type}>
{type === 'agent' ? item.name : item.model}
</div>
),
default: ({ item, type }: { item: any; type: string }) => {
let label = item.model;
if (type === 'agent') {
label = item.name;
} else if (type === 'spec') {
label = item.label;
}
return (
<div data-testid="favorite-item" data-type={type}>
{label}
</div>
);
},
}));
const createTestQueryClient = () =>
@ -107,6 +123,14 @@ describe('FavoritesList', () => {
beforeEach(() => {
jest.clearAllMocks();
mockFavorites.length = 0;
mockUseFavorites.mockImplementation(() => ({
favorites: mockFavorites,
reorderFavorites: jest.fn(),
isLoading: false,
}));
mockUseGetStartupConfig.mockImplementation(() => ({
data: { modelSpecs: { list: [] } },
}));
});
describe('rendering', () => {
@ -132,11 +156,11 @@ describe('FavoritesList', () => {
describe('missing agent handling', () => {
it('should exclude missing agents (404) from rendered favorites and render valid agents', async () => {
const validAgent: t.Agent = {
const validAgent: Agent = {
id: 'valid-agent',
name: 'Valid Agent',
author: 'test-author',
} as t.Agent;
} as Agent;
// Set up favorites with both valid and missing agent
mockFavorites.push({ agentId: 'valid-agent' }, { agentId: 'deleted-agent' });
@ -190,11 +214,11 @@ describe('FavoritesList', () => {
});
it('should treat 403 the same as 404 — agent not rendered', async () => {
const validAgent: t.Agent = {
const validAgent: Agent = {
id: 'valid-agent',
name: 'Valid Agent',
author: 'test-author',
} as t.Agent;
} as Agent;
mockFavorites.push({ agentId: 'valid-agent' }, { agentId: 'revoked-agent' });
@ -270,4 +294,103 @@ describe('FavoritesList', () => {
expect(mockReorderFavorites).toHaveBeenCalledTimes(1);
});
});
describe('model spec rendering', () => {
it('renders a spec favorite when startupConfig has a matching spec', async () => {
mockUseGetStartupConfig.mockImplementation(() => ({
data: {
modelSpecs: {
list: [
{
name: 'fast-spec',
label: 'Fast Spec',
preset: { endpoint: 'openai', model: 'gpt-5' },
},
],
},
},
}));
mockFavorites.push({ spec: 'fast-spec' });
const { findAllByTestId } = renderWithProviders(<FavoritesList />);
const items = await findAllByTestId('favorite-item');
expect(items).toHaveLength(1);
expect(items[0]).toHaveAttribute('data-type', 'spec');
expect(items[0]).toHaveTextContent('Fast Spec');
});
it('skips a spec favorite when the spec is no longer in startupConfig', () => {
mockUseGetStartupConfig.mockImplementation(() => ({
data: { modelSpecs: { list: [] } },
}));
mockFavorites.push({ spec: 'stale-spec' });
const { queryAllByTestId } = renderWithProviders(<FavoritesList />);
expect(queryAllByTestId('favorite-item')).toHaveLength(0);
});
it('calls reorderFavorites to auto-remove stale spec favorites', async () => {
const mockReorderFavorites = jest.fn().mockResolvedValue(undefined);
mockUseFavorites.mockReturnValue({
favorites: [{ spec: 'stale-spec' }],
reorderFavorites: mockReorderFavorites,
isLoading: false,
});
mockUseGetStartupConfig.mockReturnValue({
data: { modelSpecs: { list: [] } },
});
renderWithProviders(<FavoritesList />);
await waitFor(() => {
expect(mockReorderFavorites).toHaveBeenCalledWith([], true);
});
});
it('does not clean up specs when startupConfig is still loading', async () => {
const mockReorderFavorites = jest.fn().mockResolvedValue(undefined);
mockUseFavorites.mockReturnValue({
favorites: [{ spec: 'valid-spec' }],
reorderFavorites: mockReorderFavorites,
isLoading: false,
});
mockUseGetStartupConfig.mockReturnValue({ data: undefined });
renderWithProviders(<FavoritesList />);
await new Promise((r) => setTimeout(r, 50));
expect(mockReorderFavorites).not.toHaveBeenCalled();
});
it('renders a mix of agents, models, and specs', async () => {
const validAgent: Agent = {
id: 'a1',
name: 'Agent One',
author: 'me',
} as Agent;
mockUseGetStartupConfig.mockImplementation(() => ({
data: {
modelSpecs: {
list: [
{
name: 's1',
label: 'Spec One',
preset: { endpoint: 'openai', model: 'gpt-5' },
},
],
},
},
}));
mockFavorites.push({ agentId: 'a1' }, { model: 'gpt-5', endpoint: 'openai' }, { spec: 's1' });
(dataService.getAgentById as jest.Mock).mockResolvedValue(validAgent);
const { findAllByTestId } = renderWithProviders(<FavoritesList />);
const items = await findAllByTestId('favorite-item');
expect(items).toHaveLength(3);
const types = items.map((el) => el.getAttribute('data-type'));
expect(types).toEqual(['agent', 'model', 'spec']);
});
});
});

View file

@ -0,0 +1,186 @@
/**
* @jest-environment @happy-dom/jest-environment
*/
import React from 'react';
import { Provider as JotaiProvider, createStore } from 'jotai';
import { renderHook, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { favoritesAtom } from '~/store';
import useFavorites from '../useFavorites';
import type { Favorite } from '~/store/favorites';
const mockMutateAsync = jest.fn();
const mockShowToast = jest.fn();
const mockRefetch = jest.fn();
jest.mock('@librechat/client', () => ({
...jest.requireActual('@librechat/client'),
useToastContext: () => ({ showToast: mockShowToast }),
}));
jest.mock('~/data-provider', () => ({
useGetFavoritesQuery: () => ({
data: undefined,
isLoading: false,
isError: false,
error: null,
refetch: mockRefetch,
}),
useUpdateFavoritesMutation: () => ({
mutateAsync: mockMutateAsync,
isLoading: false,
error: null,
}),
}));
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
const renderUseFavorites = (initialFavorites: Favorite[] = []) => {
const store = createStore();
store.set(favoritesAtom, initialFavorites);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<JotaiProvider store={store}>{children}</JotaiProvider>
</QueryClientProvider>
);
return { ...renderHook(() => useFavorites(), { wrapper }), store };
};
describe('useFavorites — spec methods', () => {
beforeEach(() => {
mockMutateAsync.mockReset();
mockShowToast.mockReset();
mockRefetch.mockReset();
mockMutateAsync.mockResolvedValue(undefined);
});
describe('addFavoriteSpec', () => {
it('adds a new spec favorite and persists', async () => {
const { result } = renderUseFavorites([]);
await act(async () => {
result.current.addFavoriteSpec('gpt-5-spec');
});
expect(mockMutateAsync).toHaveBeenCalledWith([{ spec: 'gpt-5-spec' }]);
expect(result.current.favorites).toEqual([{ spec: 'gpt-5-spec' }]);
});
it('is a no-op when spec is already favorited', async () => {
const { result } = renderUseFavorites([{ spec: 'gpt-5-spec' }]);
await act(async () => {
result.current.addFavoriteSpec('gpt-5-spec');
});
expect(mockMutateAsync).not.toHaveBeenCalled();
});
it('preserves existing agent/model favorites when adding a spec', async () => {
const existing: Favorite[] = [{ agentId: 'a1' }, { model: 'gpt-5', endpoint: 'openai' }];
const { result } = renderUseFavorites(existing);
await act(async () => {
result.current.addFavoriteSpec('my-spec');
});
expect(mockMutateAsync).toHaveBeenCalledWith([
{ agentId: 'a1' },
{ model: 'gpt-5', endpoint: 'openai' },
{ spec: 'my-spec' },
]);
});
});
describe('removeFavoriteSpec', () => {
it('removes an existing spec favorite', async () => {
const { result } = renderUseFavorites([{ spec: 'keep' }, { spec: 'drop' }]);
await act(async () => {
result.current.removeFavoriteSpec('drop');
});
expect(mockMutateAsync).toHaveBeenCalledWith([{ spec: 'keep' }]);
});
it('still persists when the target spec is absent', async () => {
const { result } = renderUseFavorites([{ spec: 'keep' }]);
await act(async () => {
result.current.removeFavoriteSpec('missing');
});
expect(mockMutateAsync).toHaveBeenCalledWith([{ spec: 'keep' }]);
expect(result.current.favorites).toEqual([{ spec: 'keep' }]);
});
});
describe('isFavoriteSpec', () => {
it('returns false for undefined / null / empty string', () => {
const { result } = renderUseFavorites([{ spec: 'x' }]);
expect(result.current.isFavoriteSpec(undefined)).toBe(false);
expect(result.current.isFavoriteSpec(null)).toBe(false);
expect(result.current.isFavoriteSpec('')).toBe(false);
});
it('returns true when spec is present', () => {
const { result } = renderUseFavorites([{ spec: 'x' }]);
expect(result.current.isFavoriteSpec('x')).toBe(true);
});
it('returns false when spec is not present', () => {
const { result } = renderUseFavorites([{ spec: 'x' }]);
expect(result.current.isFavoriteSpec('y')).toBe(false);
});
});
describe('toggleFavoriteSpec', () => {
it('adds when absent', async () => {
const { result } = renderUseFavorites([]);
await act(async () => {
result.current.toggleFavoriteSpec('new');
});
expect(mockMutateAsync).toHaveBeenCalledWith([{ spec: 'new' }]);
});
it('removes when present', async () => {
const { result } = renderUseFavorites([{ spec: 'new' }]);
await act(async () => {
result.current.toggleFavoriteSpec('new');
});
expect(mockMutateAsync).toHaveBeenCalledWith([]);
});
});
describe('cleanFavorites (via saveFavorites)', () => {
it('filters out entries with no canonical shape', async () => {
const { result } = renderUseFavorites([]);
await act(async () => {
result.current.reorderFavorites(
[
{ agentId: 'a1' },
{} as Favorite, // stripped
{ spec: 's1' },
{ model: 'm', endpoint: 'e' },
],
true,
);
});
expect(mockMutateAsync).toHaveBeenCalledWith([
{ agentId: 'a1' },
{ spec: 's1' },
{ model: 'm', endpoint: 'e' },
]);
});
it('collapses mixed-shape entry to the first-matching canonical variant', async () => {
const { result } = renderUseFavorites([]);
await act(async () => {
result.current.reorderFavorites(
[
// agentId takes priority in cleanFavorites
{ agentId: 'a1', spec: 'ignored' } as Favorite,
],
true,
);
});
expect(mockMutateAsync).toHaveBeenCalledWith([{ agentId: 'a1' }]);
});
});
});

View file

@ -0,0 +1,83 @@
/**
* @jest-environment @happy-dom/jest-environment
*/
import React from 'react';
import { act, render } from '@testing-library/react';
import useIsActiveItem from '../useIsActiveItem';
function Probe() {
const { ref, isActive } = useIsActiveItem<HTMLDivElement>();
return <div ref={ref} data-testid="probe" data-active={isActive ? 'true' : 'false'} />;
}
const getProbe = (container: HTMLElement) =>
container.querySelector('[data-testid="probe"]') as HTMLDivElement;
describe('useIsActiveItem', () => {
it('starts with isActive=false when data-active-item is absent', () => {
const { container } = render(<Probe />);
expect(getProbe(container).getAttribute('data-active')).toBe('false');
});
it('flips isActive to true when data-active-item is added after mount', async () => {
const { container } = render(<Probe />);
const probe = getProbe(container);
await act(async () => {
probe.setAttribute('data-active-item', '');
// Allow the MutationObserver microtask to run
await Promise.resolve();
});
expect(probe.getAttribute('data-active')).toBe('true');
});
it('flips isActive back to false when data-active-item is removed', async () => {
const { container } = render(<Probe />);
const probe = getProbe(container);
await act(async () => {
probe.setAttribute('data-active-item', '');
await Promise.resolve();
});
expect(probe.getAttribute('data-active')).toBe('true');
await act(async () => {
probe.removeAttribute('data-active-item');
await Promise.resolve();
});
expect(probe.getAttribute('data-active')).toBe('false');
});
it('ignores unrelated attribute mutations', async () => {
const { container } = render(<Probe />);
const probe = getProbe(container);
await act(async () => {
probe.setAttribute('data-something-else', 'x');
await Promise.resolve();
});
expect(probe.getAttribute('data-active')).toBe('false');
});
it('disconnects the MutationObserver on unmount', async () => {
const disconnectSpy = jest.fn();
const realObserver = globalThis.MutationObserver;
class SpyObserver extends realObserver {
disconnect(): void {
disconnectSpy();
super.disconnect();
}
}
globalThis.MutationObserver = SpyObserver;
const { unmount } = render(<Probe />);
unmount();
expect(disconnectSpy).toHaveBeenCalled();
globalThis.MutationObserver = realObserver;
});
});

View file

@ -30,6 +30,7 @@ export { default as useFocusTrap } from './useFocusTrap';
export { default as useFavorites } from './useFavorites';
export { default as useChatBadges } from './useChatBadges';
export { default as useScrollToRef } from './useScrollToRef';
export { default as useIsActiveItem } from './useIsActiveItem';
export { default as useLocalStorage } from './useLocalStorage';
export { default as useDocumentTitle } from './useDocumentTitle';
export { default as useSpeechToText } from './Input/useSpeechToText';

View file

@ -11,33 +11,39 @@ import { logger } from '~/utils';
const MAX_FAVORITES = 50;
/**
* Hook for managing user favorites (pinned agents and models).
* Hook for managing user favorites (pinned agents, models, and model specs).
*
* 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 }`
* - A model spec: `{ spec: string }`
*
* @returns Object containing favorites state and helper methods for
* adding, removing, toggling, reordering, and checking favorites.
*/
/**
* Cleans favorites array to only include canonical shapes (agentId or model+endpoint).
* Cleans favorites array to only include canonical shapes (agentId, model+endpoint, or spec).
*/
const cleanFavorites = (favorites: Favorite[]): Favorite[] => {
if (!Array.isArray(favorites)) {
return [];
}
return favorites.map((f) => {
if (f.agentId) {
return { agentId: f.agentId };
}
if (f.model && f.endpoint) {
return { model: f.model, endpoint: f.endpoint };
}
return f;
});
return favorites
.map((f) => {
if (f.agentId) {
return { agentId: f.agentId };
}
if (f.model && f.endpoint) {
return { model: f.model, endpoint: f.endpoint };
}
if (f.spec) {
return { spec: f.spec };
}
return null;
})
.filter((f): f is NonNullable<typeof f> => f !== null);
};
export default function useFavorites() {
@ -137,6 +143,34 @@ export default function useFavorites() {
return favorites.some((f) => f.model === model && f.endpoint === endpoint);
};
const addFavoriteSpec = (spec: string) => {
if (favorites.some((f) => f.spec === spec)) {
return;
}
const newFavorites = [...favorites, { spec }];
saveFavorites(newFavorites);
};
const removeFavoriteSpec = (spec: string) => {
const newFavorites = favorites.filter((f) => f.spec !== spec);
saveFavorites(newFavorites);
};
const isFavoriteSpec = (spec: string | undefined | null) => {
if (!spec) {
return false;
}
return favorites.some((f) => f.spec === spec);
};
const toggleFavoriteSpec = (spec: string) => {
if (isFavoriteSpec(spec)) {
removeFavoriteSpec(spec);
} else {
addFavoriteSpec(spec);
}
};
const toggleFavoriteAgent = (agentId: string) => {
if (isFavoriteAgent(agentId)) {
removeFavoriteAgent(agentId);
@ -187,10 +221,14 @@ export default function useFavorites() {
removeFavoriteAgent,
addFavoriteModel,
removeFavoriteModel,
addFavoriteSpec,
removeFavoriteSpec,
isFavoriteAgent,
isFavoriteModel,
isFavoriteSpec,
toggleFavoriteAgent,
toggleFavoriteModel,
toggleFavoriteSpec,
reorderFavorites,
/** Whether the favorites query is currently loading */
isLoading: getFavoritesQuery.isLoading,

View file

@ -0,0 +1,33 @@
import { useRef, useState, useEffect } from 'react';
import type { RefObject } from 'react';
/**
* Mirrors Ariakit's composite `data-active-item` attribute into a React state value.
* The ref must be attached to an element that mounts synchronously on first render;
* late-mounting refs will not be observed.
*/
export default function useIsActiveItem<T extends HTMLElement = HTMLElement>(): {
ref: RefObject<T>;
isActive: boolean;
} {
const ref = useRef<T>(null);
const [isActive, setIsActive] = useState(false);
useEffect(() => {
const element = ref.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();
}, []);
return { ref, isActive };
}

View file

@ -1196,6 +1196,7 @@
"com_ui_model_parameters": "Model Parameters",
"com_ui_model_parameters_reset": "Model Parameters have been reset.",
"com_ui_model_selected": "{{0}} selected",
"com_ui_model_spec": "Model Spec",
"com_ui_more_info": "More info",
"com_ui_my_prompts": "My Prompts",
"com_ui_name": "Name",

View file

@ -1,10 +1,7 @@
import type { TUserFavorite } from 'librechat-data-provider';
import { createTabIsolatedAtom } from './jotai-utils';
export type Favorite = {
agentId?: string;
model?: string;
endpoint?: string;
};
export type Favorite = TUserFavorite;
export type FavoriteModel = {
model: string;

View file

@ -25,17 +25,11 @@ export function deleteUser(payload?: t.TDeleteUserRequest): Promise<unknown> {
return request.deleteWithOptions(endpoints.deleteUser(), { data: payload });
}
export type FavoriteItem = {
agentId?: string;
model?: string;
endpoint?: string;
};
export function getFavorites(): Promise<FavoriteItem[]> {
export function getFavorites(): Promise<q.TUserFavorite[]> {
return request.get(`${endpoints.apiBaseUrl()}/api/user/settings/favorites`);
}
export function updateFavorites(favorites: FavoriteItem[]): Promise<FavoriteItem[]> {
export function updateFavorites(favorites: q.TUserFavorite[]): Promise<q.TUserFavorite[]> {
return request.post(`${endpoints.apiBaseUrl()}/api/user/settings/favorites`, { favorites });
}

View file

@ -202,6 +202,18 @@ export interface MCPAuthValuesResponse {
authValueFlags: Record<string, boolean>;
}
/**
* User Favorites pinned agents, models, and model specs.
* Exactly one variant should be set per entry; exclusivity is enforced
* server-side in FavoritesController. Shape is loose for state-update ergonomics.
*/
export type TUserFavorite = {
agentId?: string;
model?: string;
endpoint?: string;
spec?: string;
};
/* SharePoint Graph API Token */
export type GraphTokenParams = {
scopes: string;

View file

@ -137,9 +137,10 @@ const userSchema = new Schema<IUser>(
type: [
{
_id: false,
agentId: String, // for agent
model: String, // for model
endpoint: String, // for model
agentId: { type: String, maxlength: 256 },
model: { type: String, maxlength: 256 },
endpoint: { type: String, maxlength: 256 },
spec: { type: String, maxlength: 256 },
},
],
default: [],

View file

@ -1,4 +1,5 @@
import type { Document, Types } from 'mongoose';
import type { TUserFavorite } from 'librechat-data-provider';
import { CursorPaginationParams } from '~/common';
export interface IUser extends Document {
@ -41,11 +42,7 @@ export interface IUser extends Document {
personalization?: {
memories?: boolean;
};
favorites?: Array<{
agentId?: string;
model?: string;
endpoint?: string;
}>;
favorites?: TUserFavorite[];
createdAt?: Date;
updatedAt?: Date;
/** Field for external source identification (for consistency with TPrincipal schema) */