🃏 refactor: Agent Avatar Conversation Icons and Streaming Indicators (#13563)

This commit is contained in:
Danny Avila 2026-06-06 18:39:09 -04:00 committed by GitHub
parent 98822341ed
commit 727dd3bea4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 472 additions and 249 deletions

View file

@ -41,6 +41,7 @@ export default function AddedConvo({
<EndpointIcon
conversation={addedConvo}
endpointsConfig={endpointsConfig}
agentsMap={agentsMap}
containerClassName="shadow-stroke overflow-hidden rounded-full"
context="menu-item"
size={20}

View file

@ -0,0 +1,49 @@
import { memo } from 'react';
import type { TConversation, TEndpointsConfig } from 'librechat-data-provider';
import { useAgentsMapContext, useAssistantsMapContext } from '~/Providers';
import EndpointIcon from '~/components/Endpoints/EndpointIcon';
import { areConversationIconFieldsEqual } from './utils';
import { useGetEndpointsQuery } from '~/data-provider';
const emptyEndpointsConfig = {} as TEndpointsConfig;
type EndpointIconContext = 'message' | 'nav' | 'landing' | 'menu-item';
type ConversationEndpointIconProps = {
conversation: TConversation;
className?: string;
context?: EndpointIconContext;
size?: number;
};
function ConversationEndpointIcon({
conversation,
className,
context = 'menu-item',
size = 20,
}: ConversationEndpointIconProps) {
const { data: endpointsConfig = emptyEndpointsConfig } = useGetEndpointsQuery();
const agentsMap = useAgentsMapContext();
const assistantMap = useAssistantsMapContext();
return (
<EndpointIcon
conversation={conversation}
endpointsConfig={endpointsConfig}
assistantMap={assistantMap}
agentsMap={agentsMap}
className={className}
size={size}
context={context}
/>
);
}
export default memo(ConversationEndpointIcon, (prevProps, nextProps) => {
return (
prevProps.className === nextProps.className &&
prevProps.context === nextProps.context &&
prevProps.size === nextProps.size &&
areConversationIconFieldsEqual(prevProps.conversation, nextProps.conversation)
);
});

View file

@ -1,11 +1,11 @@
import { useMemo, memo, type FC, useCallback, useEffect, useRef } from 'react';
import throttle from 'lodash/throttle';
import { ChevronDown } from 'lucide-react';
import { useRecoilValue } from 'recoil';
import { useQueryClient } from '@tanstack/react-query';
import { ChevronDown } from 'lucide-react';
import { QueryKeys } from 'librechat-data-provider';
import { Spinner, TooltipAnchor, NewChatIcon, useMediaQuery } from '@librechat/client';
import { useQueryClient } from '@tanstack/react-query';
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized';
import { Spinner, TooltipAnchor, NewChatIcon, useMediaQuery } from '@librechat/client';
import type { TConversation } from 'librechat-data-provider';
import {
useLocalize,
@ -14,9 +14,9 @@ import {
useShowMarketplace,
useNewConvo,
} from '~/hooks';
import { groupConversationsByDate, clearMessagesCache, cn } from '~/utils';
import FavoritesList from '~/components/Nav/Favorites/FavoritesList';
import { useActiveJobs } from '~/data-provider';
import { groupConversationsByDate, clearMessagesCache, cn } from '~/utils';
import Convo from './Convo';
import store from '~/store';
@ -160,38 +160,6 @@ type FlattenedItem =
| { type: 'convo'; convo: TConversation }
| { type: 'loading' };
const MemoizedConvo = memo(
({
conversation,
retainView,
toggleNav,
isGenerating,
}: {
conversation: TConversation;
retainView: () => void;
toggleNav: () => void;
isGenerating: boolean;
}) => {
return (
<Convo
conversation={conversation}
retainView={retainView}
toggleNav={toggleNav}
isGenerating={isGenerating}
/>
);
},
(prevProps, nextProps) => {
return (
prevProps.conversation.conversationId === nextProps.conversation.conversationId &&
prevProps.conversation.title === nextProps.conversation.title &&
prevProps.conversation.endpoint === nextProps.conversation.endpoint &&
prevProps.conversation.chatProjectId === nextProps.conversation.chatProjectId &&
prevProps.isGenerating === nextProps.isGenerating
);
},
);
const Conversations: FC<ConversationsProps> = ({
conversations: rawConversations,
moveToTop,
@ -352,7 +320,7 @@ const Conversations: FC<ConversationsProps> = ({
const isGenerating = activeJobIds.has(item.convo.conversationId ?? '');
return (
<MeasuredRow key={key} {...rowProps}>
<MemoizedConvo
<Convo
conversation={item.convo}
retainView={moveToTop}
toggleNav={toggleNav}

View file

@ -1,13 +1,13 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import React, { memo, useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { useParams } from 'react-router-dom';
import { Constants } from 'librechat-data-provider';
import { useToastContext, useMediaQuery } from '@librechat/client';
import type { TConversation } from 'librechat-data-provider';
import { useUpdateConversationMutation } from '~/data-provider';
import EndpointIcon from '~/components/Endpoints/EndpointIcon';
import { useNavigateToConvo, useLocalize, useShiftKey } from '~/hooks';
import { useGetEndpointsQuery } from '~/data-provider';
import ConversationEndpointIcon from './ConversationEndpointIcon';
import { useUpdateConversationMutation } from '~/data-provider';
import { areConversationRenderPropsEqual } from './utils';
import { NotificationSeverity } from '~/common';
import { ConvoOptions } from './ConvoOptions';
import RenameForm from './RenameForm';
@ -22,7 +22,7 @@ interface ConversationProps {
isGenerating?: boolean;
}
export default function Conversation({
function Conversation({
conversation,
retainView,
toggleNav,
@ -32,7 +32,6 @@ export default function Conversation({
const localize = useLocalize();
const { showToast } = useToastContext();
const { navigateToConvo } = useNavigateToConvo();
const { data: endpointsConfig } = useGetEndpointsQuery();
const currentConvoId = useMemo(() => params.conversationId, [params.conversationId]);
const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? '');
const activeConvos = useRecoilValue(store.allConversationsSelector);
@ -177,6 +176,42 @@ export default function Conversation({
isShiftHeld: isActiveConvo ? isShiftHeld : false,
};
const generatingSpinner = (
<svg
className="h-5 w-5 flex-shrink-0 animate-spin text-text-primary"
viewBox="0 0 24 24"
fill="none"
aria-label={localize('com_ui_generating')}
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
let actionVisibilityClassName =
'pointer-events-none max-w-0 scale-x-0 opacity-0 group-focus-within:pointer-events-auto group-focus-within:max-w-[60px] group-focus-within:scale-x-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:max-w-[60px] group-hover:scale-x-100 group-hover:opacity-100';
if (isGenerating) {
actionVisibilityClassName = 'pointer-events-none w-5 scale-x-100 opacity-100';
} else if (isPopoverActive || isActiveConvo) {
actionVisibilityClassName = 'pointer-events-auto scale-x-100 opacity-100';
}
let actionWidthClassName = '';
if (!isGenerating && !isPopoverActive && isActiveConvo && isShiftHeld) {
actionWidthClassName = 'max-w-[60px]';
} else if (!isGenerating) {
actionWidthClassName = 'max-w-[28px]';
}
const showConvoOptions = !renaming && (hasInteracted || isActiveConvo);
const actionContent = isGenerating
? generatingSpinner
: showConvoOptions && <ConvoOptions {...convoOptionsProps} />;
return (
<div
ref={containerRef}
@ -235,54 +270,24 @@ export default function Conversation({
isSmallScreen={isSmallScreen}
localize={localize}
>
{isGenerating ? (
<svg
className="h-5 w-5 flex-shrink-0 animate-spin text-text-primary"
viewBox="0 0 24 24"
fill="none"
aria-label={localize('com_ui_generating')}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<EndpointIcon
conversation={conversation}
endpointsConfig={endpointsConfig}
size={20}
context="menu-item"
/>
)}
<ConversationEndpointIcon conversation={conversation} size={20} context="menu-item" />
</ConvoLink>
)}
<div
className={cn(
'mr-2 flex origin-left',
isPopoverActive || isActiveConvo
? 'pointer-events-auto scale-x-100 opacity-100'
: 'pointer-events-none max-w-0 scale-x-0 opacity-0 group-focus-within:pointer-events-auto group-focus-within:max-w-[60px] group-focus-within:scale-x-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:max-w-[60px] group-hover:scale-x-100 group-hover:opacity-100',
!isPopoverActive && isActiveConvo && isShiftHeld ? 'max-w-[60px]' : 'max-w-[28px]',
'mr-2 flex origin-left items-center justify-center',
actionVisibilityClassName,
actionWidthClassName,
)}
// Removing aria-hidden to fix accessibility issue: ARIA hidden element must not be focusable or contain focusable elements
// but not sure what its original purpose was, so leaving the property commented out until it can be cleared safe to delete.
// aria-hidden={!(isPopoverActive || isActiveConvo)}
>
{/* Only render ConvoOptions when user interacts (hover/focus) or for active conversation */}
{!renaming && !isGenerating && (hasInteracted || isActiveConvo) && (
<ConvoOptions {...convoOptionsProps} />
)}
{actionContent}
</div>
</div>
);
}
export default memo(Conversation, areConversationRenderPropsEqual);

View file

@ -23,7 +23,7 @@ const ConvoLink: React.FC<ConvoLinkProps> = ({
return (
<div
className={cn(
'flex grow items-center gap-2 overflow-hidden rounded-lg px-2',
'flex min-w-0 grow items-center gap-2 overflow-hidden rounded-lg px-2',
isActiveConvo || isPopoverActive ? 'bg-surface-active-alt' : '',
)}
title={title ?? undefined}

View file

@ -1,8 +1,9 @@
import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { useRecoilValue } from 'recoil';
import { useNavigate, useLocation } from 'react-router-dom';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { useNavigate, useLocation } from 'react-router-dom';
import {
ChevronDown,
ChevronRight,
@ -13,8 +14,6 @@ import {
Pencil,
Trash2,
} from 'lucide-react';
import { QueryKeys } from 'librechat-data-provider';
import type { TChatProject, TConversation } from 'librechat-data-provider';
import {
Button,
Input,
@ -29,15 +28,17 @@ import {
NewChatIcon,
useToastContext,
} from '@librechat/client';
import type { TChatProject, TConversation } from 'librechat-data-provider';
import type { MenuItemProps } from '~/common';
import {
useProjectsInfiniteQuery,
useActiveJobs,
useConversationsInfiniteQuery,
useUpdateProjectMutation,
useDeleteProjectMutation,
} from '~/data-provider';
import { useLocalize, useLocalStorage, useNewConvo } from '~/hooks';
import ProjectCreateDialog from '~/components/Projects/ProjectCreateDialog';
import { useLocalize, useLocalStorage, useNewConvo } from '~/hooks';
import { clearMessagesCache, cn } from '~/utils';
import { NotificationSeverity } from '~/common';
import Convo from './Convo';
@ -186,16 +187,23 @@ function ProjectDeleteDialog({
const noop = () => {};
function ProjectChatsInline({
projectId,
toggleNav,
onShowAll,
}: {
type ProjectChatsInlineProps = {
projectId: string;
toggleNav: () => void;
onShowAll: () => void;
}) {
};
const ProjectChatsInline = memo(function ProjectChatsInline({
projectId,
toggleNav,
onShowAll,
}: ProjectChatsInlineProps) {
const localize = useLocalize();
const { data: activeJobsData } = useActiveJobs();
const activeJobIds = useMemo(
() => new Set(activeJobsData?.activeJobIds ?? []),
[activeJobsData?.activeJobIds],
);
const { data, isLoading } = useConversationsInfiniteQuery(
{ projectId, sortBy: 'updatedAt', sortDirection: 'desc' },
{ staleTime: 30000, cacheTime: 300000 },
@ -235,7 +243,7 @@ function ProjectChatsInline({
conversation={convo}
retainView={noop}
toggleNav={toggleNav}
isGenerating={false}
isGenerating={activeJobIds.has(convo.conversationId ?? '')}
/>
))}
{hasMore && (
@ -249,130 +257,152 @@ function ProjectChatsInline({
)}
</div>
);
}
});
function ProjectItem({
project,
toggleNav,
defaultExpanded,
}: {
ProjectChatsInline.displayName = 'ProjectChatsInline';
type ProjectItemProps = {
project: TChatProject;
toggleNav: () => void;
defaultExpanded: boolean;
}) {
const localize = useLocalize();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { newConversation } = useNewConvo();
const conversation = useRecoilValue(store.conversationByIndex(0));
const menuId = useId();
const [expanded, setExpanded] = useState(defaultExpanded);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
};
const openProject = useCallback(() => {
navigate(`/projects/${project._id}`);
toggleNav();
}, [navigate, project._id, toggleNav]);
const ProjectItem = memo(
function ProjectItem({ project, toggleNav, defaultExpanded }: ProjectItemProps) {
const localize = useLocalize();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { newConversation } = useNewConvo();
const getCurrentConversationId = useRecoilCallback(
({ snapshot }) =>
async () => {
const conversation = await snapshot.getPromise(store.conversationByIndex(0));
return conversation?.conversationId;
},
[],
);
const menuId = useId();
const [expanded, setExpanded] = useState(defaultExpanded);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const startChat = useCallback(() => {
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation({ template: { chatProjectId: project._id } });
toggleNav();
}, [conversation?.conversationId, newConversation, project._id, queryClient, toggleNav]);
const openProject = useCallback(() => {
navigate(`/projects/${project._id}`);
toggleNav();
}, [navigate, project._id, toggleNav]);
const menuItems = useMemo<MenuItemProps[]>(
() => [
{
id: `${menuId}-open`,
label: localize('com_ui_open_project'),
icon: <Folder className="size-4 text-text-secondary" aria-hidden="true" />,
onClick: openProject,
},
{
id: `${menuId}-rename`,
label: localize('com_ui_rename'),
icon: <Pencil className="size-4 text-text-secondary" aria-hidden="true" />,
onClick: () => setIsRenameOpen(true),
},
{
id: `${menuId}-delete`,
label: localize('com_ui_delete'),
icon: <Trash2 className="size-4 text-text-secondary" aria-hidden="true" />,
onClick: () => setIsDeleteOpen(true),
},
],
[localize, menuId, openProject],
);
const startChat = useCallback(async () => {
const conversationId = await getCurrentConversationId();
clearMessagesCache(queryClient, conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation({ template: { chatProjectId: project._id } });
toggleNav();
}, [getCurrentConversationId, newConversation, project._id, queryClient, toggleNav]);
return (
<li className="list-none">
<div className="group/project-row relative flex h-9 items-center rounded-lg text-sm text-text-primary transition-colors hover:bg-surface-active-alt">
<button
type="button"
onClick={() => 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"
>
<ChevronRight
className={cn(
'h-3.5 w-3.5 shrink-0 text-text-secondary transition-transform duration-200',
expanded && 'rotate-90',
)}
aria-hidden="true"
/>
<Folder className="h-4 w-4 shrink-0 text-text-secondary" aria-hidden="true" />
<span className="truncate">{project.name}</span>
</button>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 rounded-md bg-surface-active-alt opacity-0 transition-opacity group-focus-within/project-row:opacity-100 group-hover/project-row:opacity-100 has-[[data-state=open]]:opacity-100">
<TooltipAnchor
description={localize('com_ui_new_chat_in_project', { name: project.name })}
render={
<button
type="button"
aria-label={localize('com_ui_new_chat_in_project', { name: project.name })}
className={iconButtonClassName}
onClick={startChat}
>
<NewChatIcon className="h-4 w-4" />
</button>
}
/>
<DropdownPopup
portal={true}
focusLoop={true}
unmountOnHide={true}
menuId={menuId}
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
className="z-[125] min-w-44"
iconClassName="mr-2 text-text-secondary"
trigger={
<Ariakit.MenuButton
aria-label={localize('com_ui_more_options')}
className={cn(
iconButtonClassName,
isMenuOpen && 'bg-surface-active-alt text-text-primary',
)}
>
<Ellipsis className="h-4 w-4" aria-hidden="true" />
</Ariakit.MenuButton>
}
items={menuItems}
/>
const menuItems = useMemo<MenuItemProps[]>(
() => [
{
id: `${menuId}-open`,
label: localize('com_ui_open_project'),
icon: <Folder className="size-4 text-text-secondary" aria-hidden="true" />,
onClick: openProject,
},
{
id: `${menuId}-rename`,
label: localize('com_ui_rename'),
icon: <Pencil className="size-4 text-text-secondary" aria-hidden="true" />,
onClick: () => setIsRenameOpen(true),
},
{
id: `${menuId}-delete`,
label: localize('com_ui_delete'),
icon: <Trash2 className="size-4 text-text-secondary" aria-hidden="true" />,
onClick: () => setIsDeleteOpen(true),
},
],
[localize, menuId, openProject],
);
return (
<li className="list-none">
<div className="group/project-row relative flex h-9 items-center rounded-lg text-sm text-text-primary transition-colors hover:bg-surface-active-alt">
<button
type="button"
onClick={() => 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"
>
<ChevronRight
className={cn(
'h-3.5 w-3.5 shrink-0 text-text-secondary transition-transform duration-200',
expanded && 'rotate-90',
)}
aria-hidden="true"
/>
<Folder className="h-4 w-4 shrink-0 text-text-secondary" aria-hidden="true" />
<span className="truncate">{project.name}</span>
</button>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 rounded-md bg-surface-active-alt opacity-0 transition-opacity group-focus-within/project-row:opacity-100 group-hover/project-row:opacity-100 has-[[data-state=open]]:opacity-100">
<TooltipAnchor
description={localize('com_ui_new_chat_in_project', { name: project.name })}
render={
<button
type="button"
aria-label={localize('com_ui_new_chat_in_project', { name: project.name })}
className={iconButtonClassName}
onClick={startChat}
>
<NewChatIcon className="h-4 w-4" />
</button>
}
/>
<DropdownPopup
portal={true}
focusLoop={true}
unmountOnHide={true}
menuId={menuId}
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
className="z-[125] min-w-44"
iconClassName="mr-2 text-text-secondary"
trigger={
<Ariakit.MenuButton
aria-label={localize('com_ui_more_options')}
className={cn(
iconButtonClassName,
isMenuOpen && 'bg-surface-active-alt text-text-primary',
)}
>
<Ellipsis className="h-4 w-4" aria-hidden="true" />
</Ariakit.MenuButton>
}
items={menuItems}
/>
</div>
</div>
</div>
{expanded && (
<ProjectChatsInline projectId={project._id} toggleNav={toggleNav} onShowAll={openProject} />
)}
<ProjectRenameDialog open={isRenameOpen} onOpenChange={setIsRenameOpen} project={project} />
<ProjectDeleteDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen} project={project} />
</li>
);
}
{expanded && (
<ProjectChatsInline
projectId={project._id}
toggleNav={toggleNav}
onShowAll={openProject}
/>
)}
<ProjectRenameDialog open={isRenameOpen} onOpenChange={setIsRenameOpen} project={project} />
<ProjectDeleteDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen} project={project} />
</li>
);
},
(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;

View file

@ -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
);
}

View file

@ -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 (
<ConvoIconURL
iconURL={iconURL}
modelLabel={conversation?.chatGptLabel ?? conversation?.modelLabel ?? ''}
modelLabel={entityName || conversation?.chatGptLabel || conversation?.modelLabel || ''}
context={context}
endpointIconURL={endpointIconURL}
assistantAvatar={assistantAvatar}
assistantName={assistantName ?? ''}
agentAvatar={agentAvatar}
agentName={agentName}
/>
);
} else {
return (
<MinimalIcon
size={20}
iconURL={endpointIconURL}
endpoint={endpoint}
endpointType={endpointType}
model={conversation?.model}
error={false}
className={className}
size={size}
isCreatedByUser={false}
chatGptLabel={undefined}
modelLabel={undefined}

View file

@ -0,0 +1,95 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { EModelEndpoint } from 'librechat-data-provider';
import type { Agent, TConversation, TEndpointsConfig } from 'librechat-data-provider';
import EndpointIcon from '../EndpointIcon';
jest.mock('~/components/Endpoints/ConvoIconURL', () => ({
__esModule: true,
default: ({
iconURL,
modelLabel,
agentAvatar,
agentName,
}: {
iconURL: string;
modelLabel?: string;
agentAvatar?: string;
agentName?: string;
}) => (
<img
src={iconURL}
alt={modelLabel || 'Icon'}
data-testid="convo-url-icon"
data-agent-avatar={agentAvatar}
data-agent-name={agentName}
/>
),
}));
jest.mock('~/components/Endpoints/MinimalIcon', () => ({
__esModule: true,
default: ({ endpoint, iconURL }: { endpoint?: string | null; iconURL?: string }) => (
<div data-testid="minimal-icon" data-endpoint={endpoint ?? ''} data-icon-url={iconURL ?? ''} />
),
}));
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(
<EndpointIcon
conversation={conversation}
endpointsConfig={endpointsConfig}
agentsMap={{ [agent.id]: agent }}
/>,
);
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(
<EndpointIcon
conversation={conversation}
endpointsConfig={endpointsConfig}
agentsMap={{ [agent.id]: agent }}
/>,
);
expect(screen.queryByTestId('convo-url-icon')).not.toBeInTheDocument();
expect(screen.getByTestId('minimal-icon')).toHaveAttribute(
'data-endpoint',
EModelEndpoint.google,
);
});
});

View file

@ -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 (
<button
type="button"
className="flex w-full items-center gap-3 border-b border-border-light py-3 text-left outline-none transition-colors hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring-primary"
onClick={() => navigateToConvo(conversation)}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center">
<EndpointIcon
conversation={conversation}
endpointsConfig={endpointsConfig ?? {}}
size={24}
context="menu-item"
/>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-text-primary">{title}</span>
<span className="block truncate text-xs text-text-secondary">{formattedDate}</span>
</span>
</button>
);
});
return (
<button
type="button"
className="flex w-full items-center gap-3 border-b border-border-light py-3 text-left outline-none transition-colors hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring-primary"
onClick={() => navigateToConvo(conversation)}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center">
<ConversationEndpointIcon conversation={conversation} size={24} context="menu-item" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-text-primary">{title}</span>
<span className="block truncate text-xs text-text-secondary">{formattedDate}</span>
</span>
{isGenerating && (
<Spinner
className="h-4 w-4 shrink-0 text-text-primary"
aria-label={localize('com_ui_generating')}
/>
)}
</button>
);
},
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 (
<MeasuredRow key={key} {...rowProps}>
<ConversationRow conversation={item.convo} />
<ConversationRow
conversation={item.convo}
isGenerating={activeJobIds.has(item.convo.conversationId ?? '')}
/>
</MeasuredRow>
);
},
[cache, emptyLabel, flattenedItems],
[activeJobIds, cache, emptyLabel, flattenedItems],
);
const getRowHeight = useCallback(