feat: Enhance Conversations and Favorites components with expanded functionality and improved UI interactions

This commit is contained in:
Marco Beretta
2025-11-23 23:34:06 +01:00
parent 8c5b62e5b3
commit 09582573e3
7 changed files with 166 additions and 89 deletions

View File

@@ -1,10 +1,12 @@
import { useMemo, memo, type FC, useCallback } from 'react';
import { useMemo, memo, type FC, useCallback, useEffect } from 'react';
import throttle from 'lodash/throttle';
import { ChevronRight } from 'lucide-react';
import { Spinner, useMediaQuery } from '@librechat/client';
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized';
import { TConversation } from 'librechat-data-provider';
import { useLocalize, TranslationKeys } from '~/hooks';
import { groupConversationsByDate } from '~/utils';
import { useLocalize, TranslationKeys, useFavorites } from '~/hooks';
import { groupConversationsByDate, cn } from '~/utils';
import FavoritesList from '~/components/Nav/Favorites/FavoritesList';
import Convo from './Convo';
interface ConversationsProps {
@@ -15,6 +17,9 @@ interface ConversationsProps {
loadMoreConversations: () => void;
isLoading: boolean;
isSearchLoading: boolean;
scrollElement?: HTMLElement | null;
isChatsExpanded: boolean;
setIsChatsExpanded: (expanded: boolean) => void;
}
const LoadingSpinner = memo(() => {
@@ -30,10 +35,13 @@ const LoadingSpinner = memo(() => {
LoadingSpinner.displayName = 'LoadingSpinner';
const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => {
const DateLabel: FC<{ groupName: string; isFirst?: boolean }> = memo(({ groupName, isFirst }) => {
const localize = useLocalize();
return (
<h2 className="mt-2 pl-2 pt-1 text-text-secondary" style={{ fontSize: '0.7rem' }}>
<h2
className={cn('pl-3 pt-1 text-text-secondary', isFirst === true ? 'mt-0' : 'mt-2')}
style={{ fontSize: '0.7rem' }}
>
{localize(groupName as TranslationKeys) || groupName}
</h2>
);
@@ -42,6 +50,8 @@ const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => {
DateLabel.displayName = 'DateLabel';
type FlattenedItem =
| { type: 'favorites' }
| { type: 'chats-header' }
| { type: 'header'; groupName: string }
| { type: 'convo'; convo: TConversation }
| { type: 'loading' };
@@ -75,8 +85,12 @@ const Conversations: FC<ConversationsProps> = ({
loadMoreConversations,
isLoading,
isSearchLoading,
scrollElement,
isChatsExpanded,
setIsChatsExpanded,
}) => {
const localize = useLocalize();
const { favorites } = useFavorites();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const convoHeight = isSmallScreen ? 44 : 34;
@@ -92,16 +106,21 @@ const Conversations: FC<ConversationsProps> = ({
const flattenedItems = useMemo(() => {
const items: FlattenedItem[] = [];
groupedConversations.forEach(([groupName, convos]) => {
items.push({ type: 'header', groupName });
items.push(...convos.map((convo) => ({ type: 'convo' as const, convo })));
});
items.push({ type: 'favorites' });
items.push({ type: 'chats-header' });
if (isLoading) {
items.push({ type: 'loading' } as any);
if (isChatsExpanded) {
groupedConversations.forEach(([groupName, convos]) => {
items.push({ type: 'header', groupName });
items.push(...convos.map((convo) => ({ type: 'convo' as const, convo })));
});
if (isLoading) {
items.push({ type: 'loading' } as any);
}
}
return items;
}, [groupedConversations, isLoading]);
}, [groupedConversations, isLoading, isChatsExpanded]);
const cache = useMemo(
() =>
@@ -110,6 +129,12 @@ const Conversations: FC<ConversationsProps> = ({
defaultHeight: convoHeight,
keyMapper: (index) => {
const item = flattenedItems[index];
if (item.type === 'favorites') {
return 'favorites';
}
if (item.type === 'chats-header') {
return 'chats-header';
}
if (item.type === 'header') {
return `header-${index}`;
}
@@ -125,6 +150,15 @@ const Conversations: FC<ConversationsProps> = ({
[flattenedItems, convoHeight],
);
useEffect(() => {
if (cache) {
cache.clear(0, 0);
if (containerRef.current && 'recomputeRowHeights' in containerRef.current) {
containerRef.current.recomputeRowHeights(0);
}
}
}, [favorites, cache, containerRef]);
const rowRenderer = useCallback(
({ index, key, parent, style }) => {
const item = flattenedItems[index];
@@ -140,8 +174,26 @@ const Conversations: FC<ConversationsProps> = ({
);
}
let rendering: JSX.Element;
if (item.type === 'header') {
rendering = <DateLabel groupName={item.groupName} />;
if (item.type === 'favorites') {
rendering = <FavoritesList />;
} else if (item.type === 'chats-header') {
rendering = (
<button
onClick={() => setIsChatsExpanded(!isChatsExpanded)}
className="group flex w-full items-center justify-between px-3 py-2 text-xs font-bold text-text-secondary"
type="button"
>
<span className="select-none">{localize('com_ui_chats') || 'Chats'}</span>
<ChevronRight
className={cn(
'h-3 w-3 transition-transform duration-200',
isChatsExpanded ? 'rotate-90' : '',
)}
/>
</button>
);
} else if (item.type === 'header') {
rendering = <DateLabel groupName={item.groupName} isFirst={index === 2} />;
} else if (item.type === 'convo') {
rendering = (
<MemoizedConvo conversation={item.convo} retainView={moveToTop} toggleNav={toggleNav} />
@@ -180,7 +232,7 @@ const Conversations: FC<ConversationsProps> = ({
);
return (
<div className="relative flex h-full flex-col pb-2 text-sm text-text-primary">
<div className="relative flex h-full min-h-0 flex-col pb-2 text-sm text-text-primary">
{isSearchLoading ? (
<div className="flex flex-1 items-center justify-center">
<Spinner className="text-text-primary" />

View File

@@ -1,12 +1,10 @@
import React, { useRef, useCallback, useMemo } from 'react';
import { ChevronRight } from 'lucide-react';
import { useDrag, useDrop } from 'react-dnd';
import * as Collapsible from '@radix-ui/react-collapsible';
import { QueryKeys, dataService } from 'librechat-data-provider';
import { useQueries, useQueryClient } from '@tanstack/react-query';
import type { InfiniteData } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
import { useFavorites, useLocalStorage, useLocalize } from '~/hooks';
import { useFavorites } from '~/hooks';
import FavoriteItem from './FavoriteItem';
interface DraggableFavoriteItemProps {
@@ -85,10 +83,8 @@ const DraggableFavoriteItem = ({
};
export default function FavoritesList() {
const localize = useLocalize();
const queryClient = useQueryClient();
const { favorites, reorderFavorites, persistFavorites } = useFavorites();
const [isExpanded, setIsExpanded] = useLocalStorage('favoritesExpanded', true);
const agentIds = favorites.map((f) => f.agentId).filter(Boolean) as string[];
@@ -153,45 +149,39 @@ export default function FavoritesList() {
}
return (
<Collapsible.Root open={isExpanded} onOpenChange={setIsExpanded} className="flex flex-col py-2">
<Collapsible.Trigger className="group flex w-full items-center gap-2 px-3 py-1 text-xs font-bold text-text-secondary hover:text-text-primary">
<ChevronRight className="h-3 w-3 transition-transform duration-200 group-data-[state=open]:rotate-90" />
<span className="select-none">{localize('com_ui_pinned')}</span>
</Collapsible.Trigger>
<Collapsible.Content className="collapsible-content">
<div className="mt-1 flex flex-col gap-1">
{favorites.map((fav, index) => {
if (fav.agentId) {
const agent = agentsMap[fav.agentId];
if (!agent) return null;
return (
<DraggableFavoriteItem
key={fav.agentId}
id={fav.agentId}
index={index}
moveItem={moveItem}
onDrop={handleDrop}
>
<FavoriteItem item={agent} type="agent" />
</DraggableFavoriteItem>
);
} else if (fav.model && fav.endpoint) {
return (
<DraggableFavoriteItem
key={`${fav.endpoint}-${fav.model}`}
id={`${fav.endpoint}-${fav.model}`}
index={index}
moveItem={moveItem}
onDrop={handleDrop}
>
<FavoriteItem item={fav as any} type="model" />
</DraggableFavoriteItem>
);
}
return null;
})}
</div>
</Collapsible.Content>
</Collapsible.Root>
<div className="mb-2 flex flex-col pb-2">
<div className="mt-1 flex flex-col gap-1">
{favorites.map((fav, index) => {
if (fav.agentId) {
const agent = agentsMap[fav.agentId];
if (!agent) return null;
return (
<DraggableFavoriteItem
key={fav.agentId}
id={fav.agentId}
index={index}
moveItem={moveItem}
onDrop={handleDrop}
>
<FavoriteItem item={agent} type="agent" />
</DraggableFavoriteItem>
);
} else if (fav.model && fav.endpoint) {
return (
<DraggableFavoriteItem
key={`${fav.endpoint}-${fav.model}`}
id={`${fav.endpoint}-${fav.model}`}
index={index}
moveItem={moveItem}
onDrop={handleDrop}
>
<FavoriteItem item={fav as any} type="model" />
</DraggableFavoriteItem>
);
}
return null;
})}
</div>
</div>
);
}

View File

@@ -22,7 +22,6 @@ import store from '~/store';
const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav'));
const AccountSettings = lazy(() => import('./AccountSettings'));
const AgentMarketplaceButton = lazy(() => import('./AgentMarketplaceButton'));
const FavoritesList = lazy(() => import('./Favorites/FavoritesList'));
const NAV_WIDTH_DESKTOP = '260px';
const NAV_WIDTH_MOBILE = '320px';
@@ -61,6 +60,7 @@ const Nav = memo(
const [navWidth, setNavWidth] = useState(NAV_WIDTH_DESKTOP);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const [newUser, setNewUser] = useLocalStorage('newUser', true);
const [isChatsExpanded, setIsChatsExpanded] = useLocalStorage('chatsExpanded', true);
const [showLoading, setShowLoading] = useState(false);
const [tags, setTags] = useState<string[]>([]);
@@ -153,14 +153,7 @@ const Nav = memo(
}, [isFetchingNextPage, computedHasNextPage, fetchNextPage]);
const subHeaders = useMemo(
() => (
<>
<Suspense fallback={null}>
<FavoritesList />
</Suspense>
{search.enabled === true && <SearchBar isSmallScreen={isSmallScreen} />}
</>
),
() => <>{search.enabled === true && <SearchBar isSmallScreen={isSmallScreen} />}</>,
[search.enabled, isSmallScreen],
);
@@ -220,22 +213,26 @@ const Nav = memo(
aria-label={localize('com_ui_chat_history')}
className="flex h-full flex-col px-2 pb-3.5 md:px-3"
>
<div className="flex flex-1 flex-col" ref={outerContainerRef}>
<div className="flex flex-1 flex-col overflow-hidden" ref={outerContainerRef}>
<MemoNewChat
subHeaders={subHeaders}
toggleNav={toggleNavVisible}
headerButtons={headerButtons}
isSmallScreen={isSmallScreen}
/>
<Conversations
conversations={conversations}
moveToTop={moveToTop}
toggleNav={itemToggleNav}
containerRef={listRef}
loadMoreConversations={loadMoreConversations}
isLoading={isFetchingNextPage || showLoading || isLoading}
isSearchLoading={isSearchLoading}
/>
<div className="flex min-h-0 flex-grow flex-col overflow-hidden">
<Conversations
conversations={conversations}
moveToTop={moveToTop}
toggleNav={itemToggleNav}
containerRef={listRef}
loadMoreConversations={loadMoreConversations}
isLoading={isFetchingNextPage || showLoading || isLoading}
isSearchLoading={isSearchLoading}
isChatsExpanded={isChatsExpanded}
setIsChatsExpanded={setIsChatsExpanded}
/>
</div>
</div>
<Suspense fallback={null}>
<AccountSettings />

View File

@@ -109,10 +109,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref<HTMLDivEleme
return (
<div
ref={ref}
className={cn(
'group relative mt-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-2 border-transparent px-3 py-2 text-text-primary transition-all duration-200 focus-within:border-ring-primary focus-within:bg-surface-hover hover:bg-surface-hover',
isSmallScreen === true ? 'mb-2 h-14 rounded-xl' : '',
)}
className="group relative my-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-2 border-transparent px-3 py-2 text-text-primary focus-within:border-ring-primary focus-within:bg-surface-hover hover:bg-surface-hover"
>
<Search
aria-hidden="true"

View File

@@ -772,6 +772,7 @@
"com_ui_chat": "Chat",
"com_ui_chat_history": "Chat History",
"com_ui_check_internet": "Check your internet connection",
"com_ui_chats": "Chats",
"com_ui_clear": "Clear",
"com_ui_clear_all": "Clear all",
"com_ui_clear_browser_cache": "Clear your browser cache",

View File

@@ -2851,17 +2851,21 @@ html {
@keyframes slideDown {
from {
height: 0;
opacity: 0;
}
to {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
}
@keyframes slideUp {
from {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { Bot } from 'lucide-react';
import { Skeleton } from '@librechat/client';
import type t from 'librechat-data-provider';
/**
@@ -22,6 +23,40 @@ export const getAgentAvatarUrl = (agent: t.Agent | null | undefined): string | n
return null;
};
const LazyAgentAvatar = ({
url,
alt,
imgClass,
}: {
url: string;
alt: string;
imgClass: string;
}) => {
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
setIsLoaded(false);
}, [url]);
return (
<>
<img
src={url}
alt={alt}
className={imgClass}
loading="lazy"
onLoad={() => setIsLoaded(true)}
onError={() => setIsLoaded(false)}
style={{
opacity: isLoaded ? 1 : 0,
transition: 'opacity 0.2s ease-in-out',
}}
/>
{!isLoaded && <Skeleton className="absolute inset-0 rounded-full" aria-hidden="true" />}
</>
);
};
/**
* Renders an agent avatar with fallback to Bot icon
* Consistent across all agent displays
@@ -67,12 +102,13 @@ export const renderAgentAvatar = (
if (avatarUrl) {
return (
<div className={`flex items-center justify-center ${sizeClasses[size]} ${className}`}>
<img
src={avatarUrl}
<div
className={`relative flex items-center justify-center ${sizeClasses[size]} ${className}`}
>
<LazyAgentAvatar
url={avatarUrl}
alt={`${agent?.name || 'Agent'} avatar`}
className={`${sizeClasses[size]} rounded-full object-cover shadow-lg ${borderClasses}`}
loading="lazy"
imgClass={`${sizeClasses[size]} rounded-full object-cover shadow-lg ${borderClasses}`}
/>
</div>
);