diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx index 6f73f76d79..c8cef36010 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx @@ -96,7 +96,7 @@ function EndpointMenuContent({ const localize = useLocalize(); const { agentsMap, assistantsMap, modelSpecs, selectedValues, endpointSearchValues } = useModelSelectorContext(); - const { model: selectedModel, modelSpec: selectedSpec } = selectedValues; + const { modelSpec: selectedSpec } = selectedValues; const searchValue = endpointSearchValues[endpoint.value] || ''; const endpointSpecs = useMemo(() => { @@ -134,15 +134,9 @@ function EndpointMenuContent({ ))} {filteredModels - ? renderEndpointModels( - endpoint, - endpoint.models || [], - selectedModel, - filteredModels, - endpointIndex, - ) + ? renderEndpointModels(endpoint, endpoint.models || [], filteredModels, endpointIndex) : endpoint.models && - renderEndpointModels(endpoint, endpoint.models, selectedModel, undefined, endpointIndex)} + renderEndpointModels(endpoint, endpoint.models, undefined, endpointIndex)} ); } @@ -157,7 +151,7 @@ export function EndpointItem({ endpoint, endpointIndex }: EndpointItemProps) { setEndpointSearchValue, endpointRequiresUserKey, } = useModelSelectorContext(); - const { endpoint: selectedEndpoint } = selectedValues; + const { endpoint: selectedEndpoint, modelSpec: selectedSpec } = selectedValues; const searchValue = endpointSearchValues[endpoint.value] || ''; const isUserProvided = useMemo( @@ -179,7 +173,7 @@ export function EndpointItem({ endpoint, endpointIndex }: EndpointItemProps) { ); - const isEndpointSelected = selectedEndpoint === endpoint.value; + const isEndpointSelected = !selectedSpec && selectedEndpoint === endpoint.value; if (endpoint.hasModels) { const placeholder = diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx index 752788d63a..7cec4744d5 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx @@ -11,12 +11,18 @@ import { cn } from '~/utils'; interface EndpointModelItemProps { modelId: string | null; endpoint: Endpoint; - isSelected: boolean; } -export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointModelItemProps) { +export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps) { const localize = useLocalize(); - const { handleSelectModel } = useModelSelectorContext(); + const { handleSelectModel, selectedValues } = useModelSelectorContext(); + const { + endpoint: selectedEndpoint, + model: selectedModel, + modelSpec: selectedSpec, + } = selectedValues; + const isSelected = + !selectedSpec && selectedEndpoint === endpoint.value && selectedModel === modelId; const { isFavoriteModel, toggleFavoriteModel, isFavoriteAgent, toggleFavoriteAgent } = useFavorites(); @@ -147,7 +153,6 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod export function renderEndpointModels( endpoint: Endpoint | null, models: Array<{ name: string; isGlobal?: boolean }>, - selectedModel: string | null, filteredModels?: string[], endpointIndex?: number, ) { @@ -161,7 +166,6 @@ export function renderEndpointModels( key={`${endpoint.value}${indexSuffix}-${modelId}-${modelIndex}`} modelId={modelId} endpoint={endpoint} - isSelected={selectedModel === modelId} /> ), ); diff --git a/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx b/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx index 34985639c5..26831a577e 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx @@ -160,7 +160,9 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP } const isModelSelected = - selectedEndpoint === endpoint.value && selectedModel === modelId; + !selectedSpec && + selectedEndpoint === endpoint.value && + selectedModel === modelId; return ( ({ + useModelSelectorContext: () => ({ + handleSelectModel: mockHandleSelectModel, + selectedValues: mockSelectedValues, + }), +})); + +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('~/hooks', () => ({ + useLocalize: () => (key: string) => key, + useFavorites: () => ({ + isFavoriteModel: () => false, + toggleFavoriteModel: jest.fn(), + isFavoriteAgent: () => false, + toggleFavoriteAgent: jest.fn(), + }), +})); + +const baseEndpoint: Endpoint = { + value: 'anthropic', + label: 'Anthropic', + hasModels: true, + models: [{ name: 'claude-opus-4-6' }], + icon: null, +}; + +describe('EndpointModelItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders checkmark when model and endpoint match with no active spec', () => { + mockSelectedValues = { endpoint: 'anthropic', model: 'claude-opus-4-6', modelSpec: '' }; + render(); + + const menuItem = screen.getByRole('menuitem'); + expect(menuItem).toHaveAttribute('aria-selected', 'true'); + }); + + it('does NOT render checkmark when a model spec is active even if endpoint and model match', () => { + mockSelectedValues = { + endpoint: 'anthropic', + model: 'claude-opus-4-6', + modelSpec: 'my-anthropic-spec', + }; + render(); + + const menuItem = screen.getByRole('menuitem'); + expect(menuItem).not.toHaveAttribute('aria-selected'); + }); + + it('does NOT render checkmark when model matches but endpoint differs', () => { + mockSelectedValues = { endpoint: 'openai', model: 'claude-opus-4-6', modelSpec: '' }; + render(); + + const menuItem = screen.getByRole('menuitem'); + expect(menuItem).not.toHaveAttribute('aria-selected'); + }); + + it('does NOT render checkmark when endpoint matches but model differs', () => { + mockSelectedValues = { endpoint: 'anthropic', model: 'claude-sonnet-4-5', modelSpec: '' }; + render(); + + const menuItem = screen.getByRole('menuitem'); + expect(menuItem).not.toHaveAttribute('aria-selected'); + }); +}); 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 new file mode 100644 index 0000000000..8ab9235f6f --- /dev/null +++ b/client/src/components/Chat/Menus/Endpoints/components/__tests__/SearchResults.test.tsx @@ -0,0 +1,109 @@ +import { render, screen } from '@testing-library/react'; +import type { Endpoint, SelectedValues } from '~/common'; +import { SearchResults } from '../SearchResults'; + +const mockHandleSelectSpec = jest.fn(); +const mockHandleSelectModel = jest.fn(); +const mockHandleSelectEndpoint = jest.fn(); +let mockSelectedValues: SelectedValues; + +jest.mock('~/components/Chat/Menus/Endpoints/ModelSelectorContext', () => ({ + useModelSelectorContext: () => ({ + selectedValues: mockSelectedValues, + handleSelectSpec: mockHandleSelectSpec, + handleSelectModel: mockHandleSelectModel, + handleSelectEndpoint: mockHandleSelectEndpoint, + 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', () => { + const React = jest.requireActual('react'); + return { + __esModule: true, + default: () => React.createElement('span', null, 'icon'), + }; +}); + +const localize = (key: string) => key; + +const anthropicEndpoint: Endpoint = { + value: 'anthropic', + label: 'Anthropic', + hasModels: true, + models: [{ name: 'claude-opus-4-6' }, { name: 'claude-sonnet-4-5' }], + icon: null, +}; + +const noModelsEndpoint: Endpoint = { + value: 'custom', + label: 'Custom', + hasModels: false, + icon: null, +}; + +describe('SearchResults', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('marks model as selected when endpoint and model match with no active spec', () => { + mockSelectedValues = { endpoint: 'anthropic', model: 'claude-opus-4-6', modelSpec: '' }; + render( + , + ); + + const items = screen.getAllByRole('menuitem'); + const selectedItem = items.find((el) => el.getAttribute('aria-selected') === 'true'); + expect(selectedItem).toBeDefined(); + expect(selectedItem).toHaveTextContent('claude-opus-4-6'); + }); + + it('does not mark model as selected when a spec is active', () => { + mockSelectedValues = { + endpoint: 'anthropic', + model: 'claude-opus-4-6', + modelSpec: 'my-spec', + }; + render( + , + ); + + const items = screen.getAllByRole('menuitem'); + for (const item of items) { + expect(item).not.toHaveAttribute('aria-selected'); + } + }); + + it('does not mark endpoint as selected when a spec is active', () => { + mockSelectedValues = { + endpoint: 'custom', + model: '', + modelSpec: 'my-spec', + }; + render(); + + const item = screen.getByRole('menuitem'); + expect(item).not.toHaveAttribute('aria-selected'); + }); + + it('marks endpoint as selected when no spec is active and endpoint matches', () => { + mockSelectedValues = { endpoint: 'custom', model: '', modelSpec: '' }; + render(); + + const item = screen.getByRole('menuitem'); + expect(item).toHaveAttribute('aria-selected', 'true'); + }); +});