mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-25 08:56:10 +00:00
🃏 refactor: Agent Avatar Conversation Icons and Streaming Indicators (#13563)
This commit is contained in:
parent
98822341ed
commit
727dd3bea4
10 changed files with 472 additions and 249 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
47
client/src/components/Conversations/utils.ts
Normal file
47
client/src/components/Conversations/utils.ts
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue