From 6fc72e5dfd9e280125314be58f9b9e13c10c8277 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 9 Jun 2026 12:10:41 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=AB=20fix:=20Hide=20Empty=20Agents=20E?= =?UTF-8?q?ndpoint=20from=20Model=20Selector=20(#13624)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: prevent empty agents endpoint selection * fix: sort endpoint item imports --- .../Menus/Endpoints/__tests__/utils.test.ts | 12 +++ .../Endpoints/components/EndpointItem.tsx | 6 +- .../Endpoints/components/SearchResults.tsx | 5 ++ .../__tests__/EndpointItem.test.tsx | 80 +++++++++++++++++++ .../__tests__/SearchResults.test.tsx | 21 +++++ .../components/Chat/Menus/Endpoints/utils.ts | 12 +++ client/src/hooks/Endpoint/useEndpoints.ts | 11 ++- 7 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 client/src/components/Chat/Menus/Endpoints/components/__tests__/EndpointItem.test.tsx diff --git a/client/src/components/Chat/Menus/Endpoints/__tests__/utils.test.ts b/client/src/components/Chat/Menus/Endpoints/__tests__/utils.test.ts index 2a60e3a36c..a4b5069a69 100644 --- a/client/src/components/Chat/Menus/Endpoints/__tests__/utils.test.ts +++ b/client/src/components/Chat/Menus/Endpoints/__tests__/utils.test.ts @@ -11,6 +11,13 @@ const agentsEndpoint: Endpoint = { searchAliases: ['agent marketplace', 'marketplace'], }; +const disabledAgentsEndpoint: Endpoint = { + value: 'agents', + label: 'My Agents', + hasModels: false, + icon: null, +}; + describe('model selector utilities', () => { it('matches endpoint search aliases', () => { const results = filterItems([agentsEndpoint], 'marketplace', undefined, undefined); @@ -31,4 +38,9 @@ describe('model selector utilities', () => { const results = filterItems([agentsEndpoint], 'tienda', undefined, undefined, localize); expect(results).toEqual([agentsEndpoint]); }); + + it('does not match agents when there are no selectable agent options', () => { + const results = filterItems([disabledAgentsEndpoint], 'my agents', undefined, undefined); + expect(results).toEqual([]); + }); }); diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx index 5d6811b21d..fc2852edda 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx @@ -7,10 +7,10 @@ import type { TModelSpec } from 'librechat-data-provider'; import type { Endpoint } from '~/common'; import { CustomMenu as Menu, CustomMenuItem as MenuItem, CustomMenuSeparator } from '../CustomMenu'; import MarketplaceItem, { marketplaceSearchMatches } from './Marketplace'; +import { filterModels, shouldRenderEndpointOption } from '../utils'; import { useModelSelectorContext } from '../ModelSelectorContext'; import { renderEndpointModels } from './EndpointModelItem'; import { ModelSpecItem } from './ModelSpecItem'; -import { filterModels } from '../utils'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -182,6 +182,10 @@ export function EndpointItem({ endpoint, endpointIndex }: EndpointItemProps) { const isEndpointSelected = !selectedSpec && selectedEndpoint === endpoint.value; + if (!shouldRenderEndpointOption(endpoint)) { + return null; + } + if (endpoint.hasModels) { const placeholder = isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value) diff --git a/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx b/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx index e3b45adc05..990ee8542c 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx @@ -7,6 +7,7 @@ import type { Endpoint } from '~/common'; import MarketplaceItem, { marketplaceSearchMatches } from './Marketplace'; import { useModelSelectorContext } from '../ModelSelectorContext'; import { CustomMenuItem as MenuItem } from '../CustomMenu'; +import { shouldRenderEndpointOption } from '../utils'; import SpecIcon from './SpecIcon'; import { cn } from '~/utils'; @@ -103,6 +104,10 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP } else { // For an endpoint item const endpoint = item as Endpoint; + if (!shouldRenderEndpointOption(endpoint)) { + return null; + } + if (endpoint.hasModels) { const lowerQuery = searchValue.toLowerCase(); const endpointMatches = endpoint.label.toLowerCase().includes(lowerQuery); diff --git a/client/src/components/Chat/Menus/Endpoints/components/__tests__/EndpointItem.test.tsx b/client/src/components/Chat/Menus/Endpoints/components/__tests__/EndpointItem.test.tsx new file mode 100644 index 0000000000..5a8d6eca1c --- /dev/null +++ b/client/src/components/Chat/Menus/Endpoints/components/__tests__/EndpointItem.test.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { Endpoint, SelectedValues } from '~/common'; +import { EndpointItem } from '../EndpointItem'; + +const mockHandleSelectEndpoint = jest.fn(); +const mockHandleOpenKeyDialog = jest.fn(); +const mockSetEndpointSearchValue = jest.fn(); + +let mockSelectedValues: SelectedValues = { endpoint: '', model: '', modelSpec: '' }; + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, +})); + +jest.mock('~/components/Chat/Menus/Endpoints/ModelSelectorContext', () => ({ + useModelSelectorContext: () => ({ + agentsMap: undefined, + assistantsMap: undefined, + modelSpecs: [], + selectedValues: mockSelectedValues, + endpointSearchValues: {}, + handleOpenKeyDialog: mockHandleOpenKeyDialog, + handleSelectEndpoint: mockHandleSelectEndpoint, + setEndpointSearchValue: mockSetEndpointSearchValue, + endpointRequiresUserKey: () => false, + }), +})); + +jest.mock('~/components/Chat/Menus/Endpoints/CustomMenu', () => { + const React = jest.requireActual('react'); + + return { + CustomMenu: ({ children, label }: { children?: React.ReactNode; label?: React.ReactNode }) => + React.createElement('div', null, label, children), + CustomMenuItem: React.forwardRef(function MockMenuItem( + { children, ...rest }: { children?: React.ReactNode }, + ref: React.Ref, + ) { + return React.createElement('button', { ref, type: 'button', ...rest }, children); + }), + CustomMenuSeparator: () => React.createElement('hr'), + }; +}); + +const disabledAgentsEndpoint: Endpoint = { + value: 'agents', + label: 'My Agents', + hasModels: false, + icon: null, +}; + +const customEndpoint: Endpoint = { + value: 'custom', + label: 'Custom', + hasModels: false, + icon: null, +}; + +describe('EndpointItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSelectedValues = { endpoint: '', model: '', modelSpec: '' }; + }); + + it('does not render agents as a leaf endpoint when no selectable rows exist', () => { + render(); + + expect(screen.queryByText('My Agents')).not.toBeInTheDocument(); + expect(mockHandleSelectEndpoint).not.toHaveBeenCalled(); + }); + + it('keeps non-agent endpoints without models selectable', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Custom' })); + + expect(mockHandleSelectEndpoint).toHaveBeenCalledWith(customEndpoint); + }); +}); diff --git a/client/src/components/Chat/Menus/Endpoints/components/__tests__/SearchResults.test.tsx b/client/src/components/Chat/Menus/Endpoints/components/__tests__/SearchResults.test.tsx index 0be7766867..34acdd0e79 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/__tests__/SearchResults.test.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/__tests__/SearchResults.test.tsx @@ -70,6 +70,13 @@ const agentsMarketplaceEndpoint: Endpoint = { icon: null, }; +const disabledAgentsEndpoint: Endpoint = { + value: 'agents', + label: 'My Agents', + hasModels: false, + icon: null, +}; + describe('SearchResults', () => { beforeEach(() => { jest.clearAllMocks(); @@ -140,4 +147,18 @@ describe('SearchResults', () => { expect(mockNavigate).toHaveBeenCalledWith('/agents'); expect(mockHandleSelectModel).not.toHaveBeenCalled(); }); + + it('does not render agents as a selectable endpoint when marketplace and agent rows are unavailable', () => { + mockSelectedValues = { endpoint: '', model: '', modelSpec: '' }; + render( + , + ); + + expect(screen.queryByRole('menuitem', { name: 'My Agents' })).not.toBeInTheDocument(); + expect(mockHandleSelectEndpoint).not.toHaveBeenCalled(); + }); }); diff --git a/client/src/components/Chat/Menus/Endpoints/utils.ts b/client/src/components/Chat/Menus/Endpoints/utils.ts index 7712bd838f..474d3f23ea 100644 --- a/client/src/components/Chat/Menus/Endpoints/utils.ts +++ b/client/src/components/Chat/Menus/Endpoints/utils.ts @@ -16,6 +16,7 @@ export function filterItems< label: string; name?: string; value?: string; + hasModels?: boolean; models?: Array<{ name: string; isGlobal?: boolean }>; searchAliases?: string[]; showMarketplace?: boolean; @@ -33,6 +34,10 @@ export function filterItems< } return items.filter((item) => { + if (!shouldRenderEndpointOption(item)) { + return false; + } + const itemMatches = item.label.toLowerCase().includes(searchTermLower) || (item.name && item.name.toLowerCase().includes(searchTermLower)) || @@ -76,6 +81,13 @@ export function filterItems< }); } +export function shouldRenderEndpointOption(endpoint: { + value?: string; + hasModels?: boolean; +}): boolean { + return !isAgentsEndpoint(endpoint.value) || endpoint.hasModels === true; +} + export function filterModels( endpoint: Endpoint, models: string[], diff --git a/client/src/hooks/Endpoint/useEndpoints.ts b/client/src/hooks/Endpoint/useEndpoints.ts index 361ec25156..6cec38dc2f 100644 --- a/client/src/hooks/Endpoint/useEndpoints.ts +++ b/client/src/hooks/Endpoint/useEndpoints.ts @@ -84,7 +84,7 @@ export const useEndpoints = ({ ); const mappedEndpoints: Endpoint[] = useMemo(() => { - return filteredEndpoints.map((ep) => { + return filteredEndpoints.reduce((acc, ep) => { const endpointType = getEndpointField(endpointsConfig, ep, 'type'); const iconKey = getIconKey({ endpoint: ep, endpointsConfig, endpointType }); const Icon = icons[iconKey]; @@ -96,6 +96,10 @@ export const useEndpoints = ({ ep !== EModelEndpoint.agents && (modelsQuery.data?.[ep]?.length ?? 0) > 0); + if (ep === EModelEndpoint.agents && !hasModels) { + return acc; + } + // Base result object with formatted default icon const result: Endpoint = { value: ep, @@ -185,8 +189,9 @@ export const useEndpoints = ({ })); } - return result; - }); + acc.push(result); + return acc; + }, []); }, [ agents, assistants,