-
setExpanded((prev) => !prev)}
- aria-expanded={expanded}
- aria-label={project.name}
- className="flex min-w-0 flex-1 items-center gap-1.5 rounded-lg py-1.5 pl-1.5 pr-14 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
- >
-
-
- {project.name}
-
-
-
-
-
- }
- />
-
-
-
- }
- items={menuItems}
- />
+ const menuItems = useMemo(
+ () => [
+ {
+ id: `${menuId}-open`,
+ label: localize('com_ui_open_project'),
+ icon: ,
+ onClick: openProject,
+ },
+ {
+ id: `${menuId}-rename`,
+ label: localize('com_ui_rename'),
+ icon: ,
+ onClick: () => setIsRenameOpen(true),
+ },
+ {
+ id: `${menuId}-delete`,
+ label: localize('com_ui_delete'),
+ icon: ,
+ onClick: () => setIsDeleteOpen(true),
+ },
+ ],
+ [localize, menuId, openProject],
+ );
+
+ return (
+
+
+
setExpanded((prev) => !prev)}
+ aria-expanded={expanded}
+ aria-label={project.name}
+ className="flex min-w-0 flex-1 items-center gap-1.5 rounded-lg py-1.5 pl-1.5 pr-14 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
+ >
+
+
+ {project.name}
+
+
+
+
+
+ }
+ />
+
+
+
+ }
+ items={menuItems}
+ />
+
-
- {expanded && (
-
- )}
-
-
-
- );
-}
+ {expanded && (
+
+ )}
+
+
+
+ );
+ },
+ (prevProps, nextProps) =>
+ prevProps.project._id === nextProps.project._id &&
+ prevProps.project.name === nextProps.project.name &&
+ prevProps.project.updatedAt === nextProps.project.updatedAt &&
+ prevProps.defaultExpanded === nextProps.defaultExpanded &&
+ prevProps.toggleNav === nextProps.toggleNav,
+);
+
+ProjectItem.displayName = 'ProjectItem';
interface ProjectsSectionProps {
toggleNav: () => void;
diff --git a/client/src/components/Conversations/utils.ts b/client/src/components/Conversations/utils.ts
new file mode 100644
index 0000000000..3c45d1a0e2
--- /dev/null
+++ b/client/src/components/Conversations/utils.ts
@@ -0,0 +1,47 @@
+import type { TConversation } from 'librechat-data-provider';
+
+export type ConversationRenderProps = {
+ conversation: TConversation;
+ isGenerating?: boolean;
+};
+
+export function areConversationIconFieldsEqual(
+ prevConversation: TConversation,
+ nextConversation: TConversation,
+) {
+ return (
+ prevConversation.endpoint === nextConversation.endpoint &&
+ prevConversation.endpointType === nextConversation.endpointType &&
+ prevConversation.iconURL === nextConversation.iconURL &&
+ prevConversation.model === nextConversation.model &&
+ prevConversation.modelLabel === nextConversation.modelLabel &&
+ prevConversation.chatGptLabel === nextConversation.chatGptLabel &&
+ prevConversation.spec === nextConversation.spec &&
+ prevConversation.agent_id === nextConversation.agent_id &&
+ prevConversation.assistant_id === nextConversation.assistant_id
+ );
+}
+
+export function areConversationListItemFieldsEqual(
+ prevConversation: TConversation,
+ nextConversation: TConversation,
+) {
+ return (
+ areConversationIconFieldsEqual(prevConversation, nextConversation) &&
+ prevConversation.conversationId === nextConversation.conversationId &&
+ prevConversation.title === nextConversation.title &&
+ prevConversation.chatProjectId === nextConversation.chatProjectId &&
+ prevConversation.createdAt === nextConversation.createdAt &&
+ prevConversation.updatedAt === nextConversation.updatedAt
+ );
+}
+
+export function areConversationRenderPropsEqual(
+ prevProps: ConversationRenderProps,
+ nextProps: ConversationRenderProps,
+) {
+ return (
+ areConversationListItemFieldsEqual(prevProps.conversation, nextProps.conversation) &&
+ prevProps.isGenerating === nextProps.isGenerating
+ );
+}
diff --git a/client/src/components/Endpoints/EndpointIcon.tsx b/client/src/components/Endpoints/EndpointIcon.tsx
index 0625533e02..c2add9ed7a 100644
--- a/client/src/components/Endpoints/EndpointIcon.tsx
+++ b/client/src/components/Endpoints/EndpointIcon.tsx
@@ -1,66 +1,82 @@
-import { getEndpointField, isAssistantsEndpoint } from 'librechat-data-provider';
+import { getEndpointField, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
import type {
TPreset,
TConversation,
+ TAgentsMap,
TAssistantsMap,
TEndpointsConfig,
} from 'librechat-data-provider';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import MinimalIcon from '~/components/Endpoints/MinimalIcon';
-import { getIconEndpoint } from '~/utils';
+import { getAgentAvatarUrl, getIconEndpoint } from '~/utils';
import { isImageURL } from '~/utils/icons';
+const emptyEndpointsConfig = {} as TEndpointsConfig;
+
export default function EndpointIcon({
conversation,
- endpointsConfig,
+ endpointsConfig = emptyEndpointsConfig,
className = 'mr-0',
assistantMap,
+ agentsMap,
context,
+ size = 20,
}: {
conversation: TConversation | TPreset | null;
endpointsConfig: TEndpointsConfig;
containerClassName?: string;
context?: 'message' | 'nav' | 'landing' | 'menu-item';
assistantMap?: TAssistantsMap;
+ agentsMap?: TAgentsMap;
className?: string;
size?: number;
}) {
const convoIconURL = conversation?.iconURL ?? '';
- let endpoint = conversation?.endpoint;
+ const originalEndpoint = conversation?.endpoint;
+ let endpoint = originalEndpoint;
endpoint = getIconEndpoint({ endpointsConfig, iconURL: convoIconURL, endpoint });
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
+ const agent = isAgentsEndpoint(endpoint) ? agentsMap?.[conversation?.agent_id ?? ''] : null;
const assistant = isAssistantsEndpoint(endpoint)
? assistantMap?.[endpoint]?.[conversation?.assistant_id ?? '']
: null;
+ const agentAvatar = getAgentAvatarUrl(agent) ?? '';
+ const agentName = agent?.name ?? '';
const assistantAvatar = (assistant && (assistant.metadata?.avatar as string)) || '';
const assistantName = assistant && (assistant.name ?? '');
+ const entityAvatar = agentAvatar || assistantAvatar;
+ const entityName = agentName || assistantName || '';
+ const hasCustomIcon =
+ isImageURL(convoIconURL) || (convoIconURL !== '' && convoIconURL !== originalEndpoint);
- const iconURL = assistantAvatar || convoIconURL;
+ const iconURL = hasCustomIcon ? convoIconURL : entityAvatar || convoIconURL;
if (isImageURL(iconURL)) {
return (
);
} else {
return (
({
+ __esModule: true,
+ default: ({
+ iconURL,
+ modelLabel,
+ agentAvatar,
+ agentName,
+ }: {
+ iconURL: string;
+ modelLabel?: string;
+ agentAvatar?: string;
+ agentName?: string;
+ }) => (
+
+ ),
+}));
+
+jest.mock('~/components/Endpoints/MinimalIcon', () => ({
+ __esModule: true,
+ default: ({ endpoint, iconURL }: { endpoint?: string | null; iconURL?: string }) => (
+
+ ),
+}));
+
+const endpointsConfig = {
+ [EModelEndpoint.agents]: {},
+ [EModelEndpoint.google]: {},
+} as TEndpointsConfig;
+
+const agent = {
+ id: 'agent_123',
+ name: 'Research Agent',
+ avatar: {
+ filepath: '/images/agents/agent_123/avatar.png',
+ source: 'local',
+ },
+} as Agent;
+
+describe('EndpointIcon', () => {
+ it('uses the agent avatar when the agents endpoint would otherwise render its default icon', () => {
+ const conversation = {
+ endpoint: EModelEndpoint.agents,
+ agent_id: agent.id,
+ iconURL: EModelEndpoint.agents,
+ } as TConversation;
+
+ render(
+ ,
+ );
+
+ const icon = screen.getByTestId('convo-url-icon');
+ expect(icon).toHaveAttribute('src', '/images/agents/agent_123/avatar.png');
+ expect(icon).toHaveAttribute('alt', 'Research Agent');
+ });
+
+ it('keeps an explicit model spec icon ahead of the agent avatar', () => {
+ const conversation = {
+ endpoint: EModelEndpoint.agents,
+ agent_id: agent.id,
+ spec: 'research-spec',
+ iconURL: EModelEndpoint.google,
+ } as TConversation;
+
+ render(
+ ,
+ );
+
+ expect(screen.queryByTestId('convo-url-icon')).not.toBeInTheDocument();
+ expect(screen.getByTestId('minimal-icon')).toHaveAttribute(
+ 'data-endpoint',
+ EModelEndpoint.google,
+ );
+ });
+});
diff --git a/client/src/components/Projects/ProjectChatList.tsx b/client/src/components/Projects/ProjectChatList.tsx
index 9d9309f051..fe1a8aa55a 100644
--- a/client/src/components/Projects/ProjectChatList.tsx
+++ b/client/src/components/Projects/ProjectChatList.tsx
@@ -9,15 +9,16 @@ import {
type ReactNode,
} from 'react';
import throttle from 'lodash/throttle';
-import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from 'react-virtualized';
import { Spinner } from '@librechat/client';
+import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from 'react-virtualized';
import type { TConversation } from 'librechat-data-provider';
import type { MeasuredCellParent } from '~/components/Conversations/Conversations';
-import { useGetEndpointsQuery } from '~/data-provider';
+import ConversationEndpointIcon from '~/components/Conversations/ConversationEndpointIcon';
+import { areConversationRenderPropsEqual } from '~/components/Conversations/utils';
+import { DateLabel } from '~/components/Conversations/Conversations';
import { useLocalize, useNavigateToConvo } from '~/hooks';
import { groupConversationsByDate, cn } from '~/utils';
-import { DateLabel } from '~/components/Conversations/Conversations';
-import EndpointIcon from '~/components/Endpoints/EndpointIcon';
+import { useActiveJobs } from '~/data-provider';
type ChatSortField = 'updatedAt' | 'createdAt';
@@ -72,35 +73,38 @@ const LoadingRow = memo(() => {
LoadingRow.displayName = 'ProjectWorkspaceLoadingRow';
-const ConversationRow = memo(({ conversation }: { conversation: TConversation }) => {
- const { navigateToConvo } = useNavigateToConvo();
- const localize = useLocalize();
- const { data: endpointsConfig } = useGetEndpointsQuery();
- const title = conversation.title || localize('com_ui_untitled');
- const updatedAt = conversation.updatedAt || conversation.createdAt;
- const formattedDate = updatedAt ? new Date(updatedAt).toLocaleString() : '';
+const ConversationRow = memo(
+ ({ conversation, isGenerating }: { conversation: TConversation; isGenerating: boolean }) => {
+ const { navigateToConvo } = useNavigateToConvo();
+ const localize = useLocalize();
+ const title = conversation.title || localize('com_ui_untitled');
+ const updatedAt = conversation.updatedAt || conversation.createdAt;
+ const formattedDate = updatedAt ? new Date(updatedAt).toLocaleString() : '';
- return (
- navigateToConvo(conversation)}
- >
-
-
-
-
- {title}
- {formattedDate}
-
-
- );
-});
+ return (
+ navigateToConvo(conversation)}
+ >
+
+
+
+
+ {title}
+ {formattedDate}
+
+ {isGenerating && (
+
+ )}
+
+ );
+ },
+ areConversationRenderPropsEqual,
+);
ConversationRow.displayName = 'ProjectWorkspaceConversationRow';
@@ -113,6 +117,11 @@ const ProjectChatList = ({
emptyLabel,
loadMore,
}: ProjectChatListProps) => {
+ const { data: activeJobsData } = useActiveJobs();
+ const activeJobIds = useMemo(
+ () => new Set(activeJobsData?.activeJobIds ?? []),
+ [activeJobsData?.activeJobIds],
+ );
const flattenedItems = useMemo(() => {
if (isLoading) {
return [{ type: 'loading' as const }];
@@ -200,11 +209,14 @@ const ProjectChatList = ({
return (
-
+
);
},
- [cache, emptyLabel, flattenedItems],
+ [activeJobIds, cache, emptyLabel, flattenedItems],
);
const getRowHeight = useCallback(