From ed081964d6f8d0e8453f0690ece2221964f118e8 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 16 Aug 2026 11:04:25 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20fix:=20Model=20Spec=20M?= =?UTF-8?q?enu=20Label=20and=20Agent=20Avatar=20Fallbacks=20(#14887)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🏷️ fix: Model Spec Menu Label and Agent Avatar Fallbacks The selector's header already falls back from a spec's `label` to its `name` (`getSelectedValueText`), but the menu item and search result render `spec.label` bare. A spec persisted without a label therefore shows its name in the header while its row in the list is blank — selectable, but unlabeled. Specs targeting an agent had a related gap: the chat landing resolves the agent and shows its avatar, while the selector resolved icons from the spec/preset/endpoint only. An agent with an avatar still rendered a generic endpoint icon in the menu unless `iconURL` was set by hand. - Fall back to `spec.name` when `label` is absent or empty, matching the header. - Resolve the target agent's avatar as the icon when the spec defines none of its own; an explicit `iconURL` still wins. `getSpecAgentAvatarURL` returns the avatar as a primitive so the memoized `SpecIcon` compares a string rather than the identity of the agents map, and both call sites already consume `useModelSelectorContext`, so reading `agentsMap` adds no new subscription. * 🔁 refactor: Normalize Spec Labels on Ingest, Harden Agent Avatar Lookup Addresses the review findings, which fell into two patterns rather than four independent bugs. The label fallback was applied per render site, but the spec list feeds five consumers: the menu row, the search row, `filterItems`, the spec/endpoint discriminator in `SearchResults`, and `getSelectedIcon`. Guarding two of them left `filterItems` throwing on `label.toLowerCase()` and the discriminator misclassifying a label-less spec as an endpoint — reproducing, inside the fix, the exact inconsistency the fix targeted. Normalize once on ingest instead: `normalizeModelSpecs` fills a missing label from the name where the selector derives its spec list, so every consumer works from a complete spec and the per-site guards are removed. It returns the original array and the original spec objects when nothing needs filling in, so memoized consumers see no new identities. The avatar helper had the same shape of problem — a second resolution path that skipped what the existing one already handled: - Resolve via `getAgentAvatarUrl`, which supports agents persisting `avatar` as a URL string rather than an object. - Gate on `isAgentsEndpoint`. `tModelSpecPresetSchema` permits `agent_id` alongside any endpoint, so a leftover id on a non-agent spec would otherwise surface an unrelated agent's identity. - Thread the avatar through `getSelectedIcon`, so the selector trigger keeps the agent avatar after selection instead of reverting to the generic endpoint icon. * 🩹 fix: Treat Empty Spec Icon Fields as Unset `??` only skips null and undefined, so an `iconURL: ''` — what a form-backed config writer persists for an untouched field — stopped the chain and suppressed both the agent avatar and the endpoint icon, leaving the generic fallback. Resolve the first non-empty candidate instead. This matches `applyModelSpecPreset`, which already ignores an empty `iconURL`, and keeps `showIconInMenu` as the explicit way to render no icon. * 🔁 refactor: Normalize Spec Labels at the Query Boundary, Cover Favorites The context-level normalization missed a parallel consumer: the favorites sidebar reads `startupConfig.modelSpecs.list` directly, so a pinned label-less spec rendered a blank row and its icon ignored the agent avatar — the same two gaps this PR fixes in the selector. Move normalization to the true shared boundary instead of adding another per-consumer call: `useGetStartupConfig` normalizes in its query function, once per fetch, cached — so the selector, search, mentions, favorites, and provider-key reachability all read complete specs. The `ModelSelectorContext` call is removed as redundant. - `normalizeModelSpecs` is now a single pass with a lazy copy (allocated only on the first incomplete spec), replacing the `some` + `map` double scan. - `FavoriteItem` threads `agentAvatarURL` into `SpecIcon`; the list resolves it per spec from the agents map it already holds, passed as a primitive so memoization is unaffected. --- .../Chat/Menus/Endpoints/ModelSelector.tsx | 3 +- .../Menus/Endpoints/ModelSelectorContext.tsx | 1 + .../Endpoints/components/ModelSpecItem.tsx | 11 +- .../Endpoints/components/SearchResults.tsx | 9 +- .../Menus/Endpoints/components/SpecIcon.tsx | 6 +- .../components/__tests__/SpecIcon.test.tsx | 69 ++++++++++++ .../components/Chat/Menus/Endpoints/utils.ts | 4 + .../components/Nav/Favorites/FavoriteItem.tsx | 8 +- .../Nav/Favorites/FavoritesList.tsx | 2 + .../Nav/Favorites/tests/FavoriteItem.spec.tsx | 15 ++- client/src/data-provider/Endpoints/queries.ts | 10 +- client/src/utils/endpoints.spec.ts | 69 +++++++++++- client/src/utils/endpoints.ts | 100 +++++++++++++++++- 13 files changed, 291 insertions(+), 16 deletions(-) diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx index b72c4f2dcc..c761b02a8a 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx @@ -49,8 +49,9 @@ function ModelSelectorContent() { selectedValues, modelSpecs, endpointsConfig, + agentsMap, }), - [mappedEndpoints, selectedValues, modelSpecs, endpointsConfig], + [mappedEndpoints, selectedValues, modelSpecs, endpointsConfig, agentsMap], ); const selectedDisplayValue = useMemo( () => diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx index 9e450fc75e..13e70cc0a4 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx @@ -68,6 +68,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector const localize = useLocalize(); const { announcePolite } = useLiveAnnouncer(); const modelSpecs = useMemo(() => { + /** Labels are normalized at the startup-config query boundary. */ const specs = startupConfig?.modelSpecs?.list ?? []; if (!agentsMap) { return specs; diff --git a/client/src/components/Chat/Menus/Endpoints/components/ModelSpecItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/ModelSpecItem.tsx index e85b4b62de..c181d0f752 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/ModelSpecItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/ModelSpecItem.tsx @@ -5,9 +5,9 @@ import type { TModelSpec } from 'librechat-data-provider'; import { useFavorites, useLocalize, useIsActiveItem } from '~/hooks'; import { useModelSelectorContext } from '../ModelSelectorContext'; import { CustomMenuItem as MenuItem } from '../CustomMenu'; +import { cn, getSpecAgentAvatarURL } from '~/utils'; import SpecDescription from './SpecDescription'; import SpecIcon from './SpecIcon'; -import { cn } from '~/utils'; interface ModelSpecItemProps { spec: TModelSpec; @@ -19,9 +19,10 @@ interface ModelSpecItemProps { export function ModelSpecItem({ spec, isSelected, posInSet, setSize }: ModelSpecItemProps) { const localize = useLocalize(); - const { handleSelectSpec, endpointsConfig } = useModelSelectorContext(); + const { handleSelectSpec, endpointsConfig, agentsMap } = useModelSelectorContext(); const { isFavoriteSpec, toggleFavoriteSpec } = useFavorites(); const { showIconInMenu = true } = spec; + const agentAvatarURL = getSpecAgentAvatarURL(spec, agentsMap); const { ref: itemRef, isActive } = useIsActiveItem(); @@ -49,7 +50,11 @@ export function ModelSpecItem({ spec, isSelected, posInSet, setSize }: ModelSpec > {showIconInMenu && (
- +
)}
diff --git a/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx b/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx index b5676cfa7f..72541f80ab 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx @@ -8,9 +8,9 @@ import MarketplaceItem, { marketplaceSearchMatches } from './Marketplace'; import { useModelSelectorContext } from '../ModelSelectorContext'; import { CustomMenuItem as MenuItem } from '../CustomMenu'; import { shouldRenderEndpointOption } from '../utils'; +import { cn, getSpecAgentAvatarURL } from '~/utils'; import SpecDescription from './SpecDescription'; import SpecIcon from './SpecIcon'; -import { cn } from '~/utils'; interface SearchResultsProps { results: (TModelSpec | Endpoint)[] | null; @@ -25,6 +25,7 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP handleSelectModel, handleSelectEndpoint, endpointsConfig, + agentsMap, } = useModelSelectorContext(); const { @@ -78,7 +79,11 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP > {(spec.showIconInMenu ?? true) && (
- +
)}
diff --git a/client/src/components/Chat/Menus/Endpoints/components/SpecIcon.tsx b/client/src/components/Chat/Menus/Endpoints/components/SpecIcon.tsx index 2e354db08b..8370808ee2 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/SpecIcon.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/SpecIcon.tsx @@ -10,12 +10,14 @@ import { isImageURL } from '~/utils/icons'; interface SpecIconProps { currentSpec: TModelSpec; endpointsConfig: TEndpointsConfig; + /** Avatar of the agent this spec targets, used when the spec defines no icon of its own. */ + agentAvatarURL?: string; } type IconType = (props: IconMapProps) => React.JSX.Element; -const SpecIcon: React.FC = ({ currentSpec, endpointsConfig }) => { - const iconURL = getModelSpecIconURL(currentSpec); +const SpecIcon: React.FC = ({ currentSpec, endpointsConfig, agentAvatarURL }) => { + const iconURL = getModelSpecIconURL(currentSpec, agentAvatarURL); const endpoint = currentSpec.preset?.endpoint; const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL'); const iconKey = getIconKey({ endpoint, endpointsConfig, endpointIconURL }); diff --git a/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecIcon.test.tsx b/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecIcon.test.tsx index e480a4bde7..45b0f3f001 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecIcon.test.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecIcon.test.tsx @@ -87,4 +87,73 @@ describe('SpecIcon', () => { expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-icon-key', 'unknown'); }); + + it("renders the agent's avatar when the spec defines no icon of its own", () => { + const currentSpec = { + name: 'agent-spec', + label: 'Agent Spec', + preset: { endpoint: EModelEndpoint.agents, agent_id: 'agent_abc' }, + } as TModelSpec; + + render( + , + ); + + expect(screen.getByTestId('url-icon')).toHaveAttribute( + 'data-icon-url', + '/images/agent-avatar.png', + ); + }); + + /** + * Form-authored specs persist untouched icon fields as empty strings, which + * must not suppress the avatar the way a real icon would. + */ + it('treats empty icon fields as unset and still uses the agent avatar', () => { + const currentSpec = { + name: 'agent-spec', + label: 'Agent Spec', + iconURL: '', + preset: { endpoint: EModelEndpoint.agents, agent_id: 'agent_abc', iconURL: '' }, + } as unknown as TModelSpec; + + render( + , + ); + + expect(screen.getByTestId('url-icon')).toHaveAttribute( + 'data-icon-url', + '/images/agent-avatar.png', + ); + }); + + it('prefers an explicit spec icon over the agent avatar', () => { + const currentSpec = { + name: 'agent-spec', + label: 'Agent Spec', + iconURL: '/assets/explicit-logo.svg', + preset: { endpoint: EModelEndpoint.agents, agent_id: 'agent_abc' }, + } as TModelSpec; + + render( + , + ); + + expect(screen.getByTestId('url-icon')).toHaveAttribute( + 'data-icon-url', + '/assets/explicit-logo.svg', + ); + }); }); diff --git a/client/src/components/Chat/Menus/Endpoints/utils.ts b/client/src/components/Chat/Menus/Endpoints/utils.ts index 474d3f23ea..8a478321a2 100644 --- a/client/src/components/Chat/Menus/Endpoints/utils.ts +++ b/client/src/components/Chat/Menus/Endpoints/utils.ts @@ -10,6 +10,7 @@ import type { import type { useLocalize } from '~/hooks'; import SpecIcon from '~/components/Chat/Menus/Endpoints/components/SpecIcon'; import { Endpoint, SelectedValues } from '~/common'; +import { getSpecAgentAvatarURL } from '~/utils'; export function filterItems< T extends { @@ -124,11 +125,13 @@ export function getSelectedIcon({ selectedValues, modelSpecs, endpointsConfig, + agentsMap, }: { mappedEndpoints: Endpoint[]; selectedValues: SelectedValues; modelSpecs: TModelSpec[]; endpointsConfig: TEndpointsConfig; + agentsMap?: TAgentsMap; }): React.ReactNode | null { const { endpoint, model, modelSpec } = selectedValues; @@ -144,6 +147,7 @@ export function getSelectedIcon({ return React.createElement(SpecIcon, { currentSpec: spec, endpointsConfig, + agentAvatarURL: getSpecAgentAvatarURL(spec, agentsMap), }); } diff --git a/client/src/components/Nav/Favorites/FavoriteItem.tsx b/client/src/components/Nav/Favorites/FavoriteItem.tsx index f29ca8d440..c0a76628a6 100644 --- a/client/src/components/Nav/Favorites/FavoriteItem.tsx +++ b/client/src/components/Nav/Favorites/FavoriteItem.tsx @@ -37,6 +37,8 @@ type SpecFavoriteProps = FavoriteItemBaseProps & { item: TModelSpec; onSelectSpec?: (spec: TModelSpec) => void; endpointsConfig?: TEndpointsConfig; + /** Avatar of the agent the spec targets, used when the spec defines no icon of its own. */ + agentAvatarURL?: string; }; type FavoriteItemProps = AgentFavoriteProps | ModelFavoriteProps | SpecFavoriteProps; @@ -91,7 +93,11 @@ export default function FavoriteItem(props: FavoriteItemProps) { if (props.type === 'spec') { return (
- +
); } diff --git a/client/src/components/Nav/Favorites/FavoritesList.tsx b/client/src/components/Nav/Favorites/FavoritesList.tsx index e156b5e46a..889d72a91a 100644 --- a/client/src/components/Nav/Favorites/FavoritesList.tsx +++ b/client/src/components/Nav/Favorites/FavoritesList.tsx @@ -17,6 +17,7 @@ import { import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider'; import { useAssistantsMapContext, useAgentsMapContext } from '~/Providers'; import useSelectMention from '~/hooks/Input/useSelectMention'; +import { getSpecAgentAvatarURL } from '~/utils'; import FavoriteItem from './FavoriteItem'; import store from '~/store'; @@ -458,6 +459,7 @@ function FavoritesList({ onSelectSpec={onSelectSpec} onRemoveFocus={handleRemoveFocus} endpointsConfig={endpointsConfig} + agentAvatarURL={getSpecAgentAvatarURL(spec, agentsMap)} /> ); diff --git a/client/src/components/Nav/Favorites/tests/FavoriteItem.spec.tsx b/client/src/components/Nav/Favorites/tests/FavoriteItem.spec.tsx index ff79dc4354..ed018c471a 100644 --- a/client/src/components/Nav/Favorites/tests/FavoriteItem.spec.tsx +++ b/client/src/components/Nav/Favorites/tests/FavoriteItem.spec.tsx @@ -18,7 +18,9 @@ jest.mock('~/hooks', () => ({ jest.mock('~/components/Chat/Menus/Endpoints/components/SpecIcon', () => ({ __esModule: true, - default: () => , + default: ({ agentAvatarURL }: { agentAvatarURL?: string }) => ( + + ), })); jest.mock('~/components/Endpoints/MinimalIcon', () => ({ @@ -108,6 +110,17 @@ describe('FavoriteItem', () => { expect(screen.getByTestId('spec-icon')).toBeInTheDocument(); }); + /** The pinned row must show the same agent avatar as the selector row. */ + it('threads the agent avatar through to SpecIcon', () => { + render( + , + ); + expect(screen.getByTestId('spec-icon')).toHaveAttribute( + 'data-agent-avatar-url', + '/images/agent-avatar.png', + ); + }); + it('has aria-label formatted as "