✨ feat: Enhance Conversations and Favorites components with expanded functionality and improved UI interactions
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user