diff --git a/client/src/components/Chat/Messages/MessageIcon.tsx b/client/src/components/Chat/Messages/MessageIcon.tsx index 0857bb58a9..d63e0d2071 100644 --- a/client/src/components/Chat/Messages/MessageIcon.tsx +++ b/client/src/components/Chat/Messages/MessageIcon.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, memo } from 'react'; +import { useMemo, memo } from 'react'; import { getEndpointField } from 'librechat-data-provider'; import type { Assistant, Agent } from 'librechat-data-provider'; import type { TMessageIcon } from '~/common'; @@ -7,73 +7,101 @@ import { useGetEndpointsQuery } from '~/data-provider'; import { getIconEndpoint, logger } from '~/utils'; import Icon from '~/components/Endpoints/Icon'; -const MessageIcon = memo( - ({ - iconData, - assistant, - agent, - }: { - iconData?: TMessageIcon; - assistant?: Assistant; - agent?: Agent; - }) => { - logger.log('icon_data', iconData, assistant, agent); - const { data: endpointsConfig } = useGetEndpointsQuery(); +type MessageIconProps = { + iconData?: TMessageIcon; + assistant?: Assistant; + agent?: Agent; +}; - const agentName = useMemo(() => agent?.name ?? '', [agent]); - const agentAvatar = useMemo(() => agent?.avatar?.filepath ?? '', [agent]); - const assistantName = useMemo(() => assistant?.name ?? '', [assistant]); - const assistantAvatar = useMemo(() => assistant?.metadata?.avatar ?? '', [assistant]); +/** + * Compares only the fields MessageIcon actually renders. + * `agent.id` / `assistant.id` are intentionally omitted because + * this component renders display properties only, not identity-derived content. + */ +export function arePropsEqual(prev: MessageIconProps, next: MessageIconProps): boolean { + if (prev.iconData?.endpoint !== next.iconData?.endpoint) { + return false; + } + if (prev.iconData?.model !== next.iconData?.model) { + return false; + } + if (prev.iconData?.iconURL !== next.iconData?.iconURL) { + return false; + } + if (prev.iconData?.modelLabel !== next.iconData?.modelLabel) { + return false; + } + if (prev.iconData?.isCreatedByUser !== next.iconData?.isCreatedByUser) { + return false; + } + if (prev.agent?.name !== next.agent?.name) { + return false; + } + if (prev.agent?.avatar?.filepath !== next.agent?.avatar?.filepath) { + return false; + } + if (prev.assistant?.name !== next.assistant?.name) { + return false; + } + if (prev.assistant?.metadata?.avatar !== next.assistant?.metadata?.avatar) { + return false; + } + return true; +} - const avatarURL = useMemo(() => { - let result = ''; - if (assistant) { - result = assistantAvatar; - } else if (agent) { - result = agentAvatar; - } - return result; - }, [assistant, agent, assistantAvatar, agentAvatar]); +const MessageIcon = memo(({ iconData, assistant, agent }: MessageIconProps) => { + logger.log('icon_data', iconData, assistant, agent); + const { data: endpointsConfig } = useGetEndpointsQuery(); - const iconURL = iconData?.iconURL; - const endpoint = useMemo( - () => getIconEndpoint({ endpointsConfig, iconURL, endpoint: iconData?.endpoint }), - [endpointsConfig, iconURL, iconData?.endpoint], - ); + const agentName = agent?.name ?? ''; + const agentAvatar = agent?.avatar?.filepath ?? ''; + const assistantName = assistant?.name ?? ''; + const assistantAvatar = assistant?.metadata?.avatar ?? ''; + let avatarURL = ''; + if (assistant) { + avatarURL = assistantAvatar; + } else if (agent) { + avatarURL = agentAvatar; + } - const endpointIconURL = useMemo( - () => getEndpointField(endpointsConfig, endpoint, 'iconURL'), - [endpointsConfig, endpoint], - ); + const iconURL = iconData?.iconURL; + const endpoint = useMemo( + () => getIconEndpoint({ endpointsConfig, iconURL, endpoint: iconData?.endpoint }), + [endpointsConfig, iconURL, iconData?.endpoint], + ); - if (iconData?.isCreatedByUser !== true && iconURL != null && iconURL.includes('http')) { - return ( - - ); - } + const endpointIconURL = useMemo( + () => getEndpointField(endpointsConfig, endpoint, 'iconURL'), + [endpointsConfig, endpoint], + ); + if (iconData?.isCreatedByUser !== true && iconURL != null && iconURL.includes('http')) { return ( - ); - }, -); + } + + return ( + + ); +}, arePropsEqual); MessageIcon.displayName = 'MessageIcon'; diff --git a/client/src/components/Chat/Messages/__tests__/MessageIcon.test.ts b/client/src/components/Chat/Messages/__tests__/MessageIcon.test.ts new file mode 100644 index 0000000000..db4e9df316 --- /dev/null +++ b/client/src/components/Chat/Messages/__tests__/MessageIcon.test.ts @@ -0,0 +1,154 @@ +import type { Agent, Assistant } from 'librechat-data-provider'; +import type { TMessageIcon } from '~/common'; + +// Mock all module-level imports so we can import the pure arePropsEqual function +// without pulling in React component dependencies +jest.mock('librechat-data-provider', () => ({ + getEndpointField: jest.fn(), +})); +jest.mock('~/components/Endpoints/ConvoIconURL', () => jest.fn()); +jest.mock('~/data-provider', () => ({ useGetEndpointsQuery: jest.fn(() => ({ data: {} })) })); +jest.mock('~/utils', () => ({ getIconEndpoint: jest.fn(), logger: { log: jest.fn() } })); +jest.mock('~/components/Endpoints/Icon', () => jest.fn()); + +import { arePropsEqual } from '../MessageIcon'; + +const baseIconData: TMessageIcon = { + endpoint: 'agents', + model: 'agent_123', + iconURL: '/images/avatar.png', + modelLabel: 'Test Agent', + isCreatedByUser: false, +}; + +const makeAgent = (overrides?: Partial): Agent => + ({ + id: 'agent_123', + name: 'Atlas', + avatar: { filepath: '/avatars/atlas.png' }, + ...overrides, + }) as Agent; + +const makeAssistant = (overrides?: Partial): Assistant => + ({ + id: 'asst_123', + name: 'Helper', + metadata: { avatar: '/avatars/helper.png' }, + ...overrides, + }) as Assistant; + +describe('MessageIcon arePropsEqual', () => { + it('returns true when agent reference changes but display fields are identical', () => { + const agent1 = makeAgent(); + const agent2 = makeAgent(); + expect(agent1).not.toBe(agent2); + expect( + arePropsEqual( + { iconData: baseIconData, agent: agent1 }, + { iconData: baseIconData, agent: agent2 }, + ), + ).toBe(true); + }); + + it('returns false when agent name changes', () => { + expect( + arePropsEqual( + { iconData: baseIconData, agent: makeAgent({ name: 'Atlas' }) }, + { iconData: baseIconData, agent: makeAgent({ name: 'Hermes' }) }, + ), + ).toBe(false); + }); + + it('returns false when agent avatar filepath changes', () => { + expect( + arePropsEqual( + { iconData: baseIconData, agent: makeAgent({ avatar: { filepath: '/a.png' } }) }, + { iconData: baseIconData, agent: makeAgent({ avatar: { filepath: '/b.png' } }) }, + ), + ).toBe(false); + }); + + it('returns true when assistant reference changes but display fields are identical', () => { + const asst1 = makeAssistant(); + const asst2 = makeAssistant(); + expect(asst1).not.toBe(asst2); + expect( + arePropsEqual( + { iconData: baseIconData, assistant: asst1 }, + { iconData: baseIconData, assistant: asst2 }, + ), + ).toBe(true); + }); + + it('returns false when assistant name changes', () => { + expect( + arePropsEqual( + { iconData: baseIconData, assistant: makeAssistant({ name: 'Helper' }) }, + { iconData: baseIconData, assistant: makeAssistant({ name: 'Wizard' }) }, + ), + ).toBe(false); + }); + + it('returns false when assistant avatar changes', () => { + expect( + arePropsEqual( + { iconData: baseIconData, assistant: makeAssistant({ metadata: { avatar: '/a.png' } }) }, + { iconData: baseIconData, assistant: makeAssistant({ metadata: { avatar: '/b.png' } }) }, + ), + ).toBe(false); + }); + + it('returns true when iconData reference changes but fields are identical', () => { + const iconData1 = { ...baseIconData }; + const iconData2 = { ...baseIconData }; + expect( + arePropsEqual( + { iconData: iconData1, agent: makeAgent() }, + { iconData: iconData2, agent: makeAgent() }, + ), + ).toBe(true); + }); + + it('returns false when iconData endpoint changes', () => { + expect( + arePropsEqual( + { iconData: { ...baseIconData, endpoint: 'agents' } }, + { iconData: { ...baseIconData, endpoint: 'openAI' } }, + ), + ).toBe(false); + }); + + it('returns false when iconData iconURL changes', () => { + expect( + arePropsEqual( + { iconData: { ...baseIconData, iconURL: '/a.png' } }, + { iconData: { ...baseIconData, iconURL: '/b.png' } }, + ), + ).toBe(false); + }); + + it('returns true when both agent and assistant are undefined', () => { + expect(arePropsEqual({ iconData: baseIconData }, { iconData: baseIconData })).toBe(true); + }); + + // avatarURL and display strings both remain '' in both states — nothing renders differently, + // so suppressing the re-render is correct even though the agent prop went from undefined to defined. + it('returns true when agent transitions from undefined to object with undefined display fields', () => { + const agentNoFields = makeAgent({ name: undefined, avatar: undefined }); + expect( + arePropsEqual( + { iconData: baseIconData, agent: undefined }, + { iconData: baseIconData, agent: agentNoFields }, + ), + ).toBe(true); + }); + + it('returns false when agent transitions from defined to undefined', () => { + expect( + arePropsEqual( + { iconData: baseIconData, agent: makeAgent() }, + { iconData: baseIconData, agent: undefined }, + ), + ).toBe(false); + }); +});