📌 fix: Pin Agents and Models (#10808)

* fix(nav): handle search disabled/error states to stop skeleton loading

* fix(ui): correct chevron direction for chats expand/collapse toggle

* feat(Conversations): Introduce MeasuredRow and ChatsHeader components for improved rendering and layout

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2025-12-04 21:56:43 +01:00 committed by Danny Avila
parent 99f8bd2ce6
commit e6288c379c
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
2 changed files with 106 additions and 48 deletions

View file

@ -1,16 +1,26 @@
import { useMemo, memo, type FC, useCallback, useEffect, useRef } from 'react'; import { useMemo, memo, type FC, useCallback, useEffect, useRef } from 'react';
import throttle from 'lodash/throttle'; import throttle from 'lodash/throttle';
import { ChevronRight } from 'lucide-react'; import { ChevronDown } from 'lucide-react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { Spinner, useMediaQuery } from '@librechat/client'; import { Spinner, useMediaQuery } from '@librechat/client';
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized'; import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized';
import { TConversation } from 'librechat-data-provider'; import type { TConversation } from 'librechat-data-provider';
import { useLocalize, TranslationKeys, useFavorites, useShowMarketplace } from '~/hooks'; import { useLocalize, TranslationKeys, useFavorites, useShowMarketplace } from '~/hooks';
import FavoritesList from '~/components/Nav/Favorites/FavoritesList'; import FavoritesList from '~/components/Nav/Favorites/FavoritesList';
import { groupConversationsByDate, cn } from '~/utils'; import { groupConversationsByDate, cn } from '~/utils';
import Convo from './Convo'; import Convo from './Convo';
import store from '~/store'; import store from '~/store';
export type CellPosition = {
columnIndex: number;
rowIndex: number;
};
export type MeasuredCellParent = {
invalidateCellSizeAfterRender?: ((cell: CellPosition) => void) | undefined;
recomputeGridSize?: ((cell: CellPosition) => void) | undefined;
};
interface ConversationsProps { interface ConversationsProps {
conversations: Array<TConversation | null>; conversations: Array<TConversation | null>;
moveToTop: () => void; moveToTop: () => void;
@ -23,6 +33,30 @@ interface ConversationsProps {
setIsChatsExpanded: (expanded: boolean) => void; setIsChatsExpanded: (expanded: boolean) => void;
} }
interface MeasuredRowProps {
cache: CellMeasurerCache;
rowKey: string;
parent: MeasuredCellParent;
index: number;
style: React.CSSProperties;
children: React.ReactNode;
}
/** Reusable wrapper for virtualized row measurement */
const MeasuredRow: FC<MeasuredRowProps> = memo(
({ cache, rowKey, parent, index, style, children }) => (
<CellMeasurer cache={cache} columnIndex={0} key={rowKey} parent={parent} rowIndex={index}>
{({ registerChild }) => (
<div ref={registerChild as React.LegacyRef<HTMLDivElement>} style={style}>
{children}
</div>
)}
</CellMeasurer>
),
);
MeasuredRow.displayName = 'MeasuredRow';
const LoadingSpinner = memo(() => { const LoadingSpinner = memo(() => {
const localize = useLocalize(); const localize = useLocalize();
@ -36,6 +70,30 @@ const LoadingSpinner = memo(() => {
LoadingSpinner.displayName = 'LoadingSpinner'; LoadingSpinner.displayName = 'LoadingSpinner';
interface ChatsHeaderProps {
isExpanded: boolean;
onToggle: () => void;
}
/** Collapsible header for the Chats section */
const ChatsHeader: FC<ChatsHeaderProps> = memo(({ isExpanded, onToggle }) => {
const localize = useLocalize();
return (
<button
onClick={onToggle}
className="group flex w-full items-center justify-between px-1 py-2 text-xs font-bold text-text-secondary"
type="button"
>
<span className="select-none">{localize('com_ui_chats')}</span>
<ChevronDown
className={cn('h-3 w-3 transition-transform duration-200', isExpanded ? 'rotate-180' : '')}
/>
</button>
);
});
ChatsHeader.displayName = 'ChatsHeader';
const DateLabel: FC<{ groupName: string; isFirst?: boolean }> = memo(({ groupName, isFirst }) => { const DateLabel: FC<{ groupName: string; isFirst?: boolean }> = memo(({ groupName, isFirst }) => {
const localize = useLocalize(); const localize = useLocalize();
return ( return (
@ -188,62 +246,60 @@ const Conversations: FC<ConversationsProps> = ({
const rowRenderer = useCallback( const rowRenderer = useCallback(
({ index, key, parent, style }) => { ({ index, key, parent, style }) => {
const item = flattenedItems[index]; const item = flattenedItems[index];
const rowProps = { cache, rowKey: key, parent, index, style };
if (item.type === 'loading') { if (item.type === 'loading') {
return ( return (
<CellMeasurer cache={cache} columnIndex={0} key={key} parent={parent} rowIndex={index}> <MeasuredRow {...rowProps}>
{({ registerChild }) => (
<div ref={registerChild} style={style}>
<LoadingSpinner /> <LoadingSpinner />
</div> </MeasuredRow>
)}
</CellMeasurer>
); );
} }
let rendering: JSX.Element;
if (item.type === 'favorites') { if (item.type === 'favorites') {
rendering = ( return (
<MeasuredRow {...rowProps}>
<FavoritesList <FavoritesList
isSmallScreen={isSmallScreen} isSmallScreen={isSmallScreen}
toggleNav={toggleNav} toggleNav={toggleNav}
onHeightChange={clearFavoritesCache} onHeightChange={clearFavoritesCache}
/> />
</MeasuredRow>
); );
} else if (item.type === 'chats-header') { }
rendering = (
<button if (item.type === 'chats-header') {
onClick={() => setIsChatsExpanded(!isChatsExpanded)} return (
className="group flex w-full items-center justify-between px-1 py-2 text-xs font-bold text-text-secondary" <MeasuredRow {...rowProps}>
type="button" <ChatsHeader
> isExpanded={isChatsExpanded}
<span className="select-none">{localize('com_ui_chats')}</span> onToggle={() => setIsChatsExpanded(!isChatsExpanded)}
<ChevronRight
className={cn(
'h-3 w-3 transition-transform duration-200',
isChatsExpanded ? 'rotate-90' : '',
)}
/> />
</button> </MeasuredRow>
); );
} else if (item.type === 'header') { }
if (item.type === 'header') {
// First date header index depends on whether favorites row is included // First date header index depends on whether favorites row is included
// With favorites: [favorites, chats-header, first-header] → index 2 // With favorites: [favorites, chats-header, first-header] → index 2
// Without favorites: [chats-header, first-header] → index 1 // Without favorites: [chats-header, first-header] → index 1
const firstHeaderIndex = shouldShowFavorites ? 2 : 1; const firstHeaderIndex = shouldShowFavorites ? 2 : 1;
rendering = <DateLabel groupName={item.groupName} isFirst={index === firstHeaderIndex} />; return (
} else if (item.type === 'convo') { <MeasuredRow {...rowProps}>
rendering = ( <DateLabel groupName={item.groupName} isFirst={index === firstHeaderIndex} />
<MemoizedConvo conversation={item.convo} retainView={moveToTop} toggleNav={toggleNav} /> </MeasuredRow>
); );
} }
if (item.type === 'convo') {
return ( return (
<CellMeasurer cache={cache} columnIndex={0} key={key} parent={parent} rowIndex={index}> <MeasuredRow {...rowProps}>
{({ registerChild }) => ( <MemoizedConvo conversation={item.convo} retainView={moveToTop} toggleNav={toggleNav} />
<div ref={registerChild} style={style} className=""> </MeasuredRow>
{rendering}
</div>
)}
</CellMeasurer>
); );
}
return null;
}, },
[ [
cache, cache,
@ -253,7 +309,6 @@ const Conversations: FC<ConversationsProps> = ({
clearFavoritesCache, clearFavoritesCache,
isSmallScreen, isSmallScreen,
isChatsExpanded, isChatsExpanded,
localize,
setIsChatsExpanded, setIsChatsExpanded,
shouldShowFavorites, shouldShowFavorites,
], ],
@ -300,10 +355,10 @@ const Conversations: FC<ConversationsProps> = ({
overscanRowCount={10} overscanRowCount={10}
aria-readonly={false} aria-readonly={false}
className="outline-none" className="outline-none"
style={{ outline: 'none' }}
aria-label="Conversations" aria-label="Conversations"
onRowsRendered={handleRowsRendered} onRowsRendered={handleRowsRendered}
tabIndex={-1} tabIndex={-1}
style={{ outline: 'none', scrollbarGutter: 'stable' }}
/> />
)} )}
</AutoSizer> </AutoSizer>

View file

@ -10,9 +10,12 @@ export default function useSearchEnabled(isAuthenticated: boolean) {
useEffect(() => { useEffect(() => {
if (searchEnabledQuery.data === true) { if (searchEnabledQuery.data === true) {
setSearch((prev) => ({ ...prev, enabled: searchEnabledQuery.data })); setSearch((prev) => ({ ...prev, enabled: true }));
} else if (searchEnabledQuery.data === false) {
setSearch((prev) => ({ ...prev, enabled: false }));
} else if (searchEnabledQuery.isError) { } else if (searchEnabledQuery.isError) {
logger.error('Failed to get search enabled: ', searchEnabledQuery.error); logger.error('Failed to get search enabled: ', searchEnabledQuery.error);
setSearch((prev) => ({ ...prev, enabled: false }));
} }
}, [searchEnabledQuery.data, searchEnabledQuery.error, searchEnabledQuery.isError, setSearch]); }, [searchEnabledQuery.data, searchEnabledQuery.error, searchEnabledQuery.isError, setSearch]);