mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +01:00
📌 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:
parent
99f8bd2ce6
commit
e6288c379c
2 changed files with 106 additions and 48 deletions
|
|
@ -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 }) => (
|
<LoadingSpinner />
|
||||||
<div ref={registerChild} style={style}>
|
</MeasuredRow>
|
||||||
<LoadingSpinner />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CellMeasurer>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let rendering: JSX.Element;
|
|
||||||
if (item.type === 'favorites') {
|
if (item.type === 'favorites') {
|
||||||
rendering = (
|
return (
|
||||||
<FavoritesList
|
<MeasuredRow {...rowProps}>
|
||||||
isSmallScreen={isSmallScreen}
|
<FavoritesList
|
||||||
toggleNav={toggleNav}
|
isSmallScreen={isSmallScreen}
|
||||||
onHeightChange={clearFavoritesCache}
|
toggleNav={toggleNav}
|
||||||
/>
|
onHeightChange={clearFavoritesCache}
|
||||||
);
|
|
||||||
} else if (item.type === 'chats-header') {
|
|
||||||
rendering = (
|
|
||||||
<button
|
|
||||||
onClick={() => setIsChatsExpanded(!isChatsExpanded)}
|
|
||||||
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>
|
|
||||||
<ChevronRight
|
|
||||||
className={cn(
|
|
||||||
'h-3 w-3 transition-transform duration-200',
|
|
||||||
isChatsExpanded ? 'rotate-90' : '',
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</MeasuredRow>
|
||||||
);
|
);
|
||||||
} else if (item.type === 'header') {
|
}
|
||||||
|
|
||||||
|
if (item.type === 'chats-header') {
|
||||||
|
return (
|
||||||
|
<MeasuredRow {...rowProps}>
|
||||||
|
<ChatsHeader
|
||||||
|
isExpanded={isChatsExpanded}
|
||||||
|
onToggle={() => setIsChatsExpanded(!isChatsExpanded)}
|
||||||
|
/>
|
||||||
|
</MeasuredRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
|
||||||
<CellMeasurer cache={cache} columnIndex={0} key={key} parent={parent} rowIndex={index}>
|
if (item.type === 'convo') {
|
||||||
{({ registerChild }) => (
|
return (
|
||||||
<div ref={registerChild} style={style} className="">
|
<MeasuredRow {...rowProps}>
|
||||||
{rendering}
|
<MemoizedConvo conversation={item.convo} retainView={moveToTop} toggleNav={toggleNav} />
|
||||||
</div>
|
</MeasuredRow>
|
||||||
)}
|
);
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue