📐 style: Resolve Stale Active Sidebar Panel and Favorites Row Height (#12366)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Publish `@librechat/client` to NPM / build-and-publish (push) Waiting to run
Publish `librechat-data-provider` to NPM / build (push) Waiting to run
Publish `librechat-data-provider` to NPM / publish-npm (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions

* 🐛 fix: Sidebar favorites row height stuck at stale measurement

Use a content-aware cache key for the favorites row in the virtualized
sidebar list. The CellMeasurerCache keyMapper now encodes
favorites.length, showAgentMarketplace, and isFavoritesLoading into the
key so the cache naturally invalidates when the content shape changes,
forcing CellMeasurer to re-measure from scratch instead of returning a
stale height from a transient render state.

Remove the onHeightChange callback and its useEffect from FavoritesList
since the content-aware key handles all height-affecting state
transitions.

* test: Add unit tests for Conversations component favorites height caching

Introduced a new test suite for the Conversations component to validate the behavior of the favorites CellMeasurerCache. The tests ensure that the cache correctly invalidates when the favorites count changes, loading state transitions occur, and marketplace visibility toggles. This enhances the reliability of the component's rendering logic and ensures proper height management in the virtualized list.

* fix: Validate active sidebar panel against available links

The `side:active-panel` localStorage key was shared with the old
right-side panel. On first load of the unified sidebar, a stale value
(e.g. 'hide-panel', or a conditional panel not currently available)
would match no link, leaving the expanded panel empty.

Add `resolveActivePanel` as derived state in both Nav and ExpandedPanel
so content and icon highlight always fall back to the first link when
the stored value doesn't match any available link.

* refactor: Remove redundant new-chat button from Agent Marketplace header

The sidebar icon strip already provides a new chat button, making the
sticky header in the marketplace page unnecessary.

* chore: import order and linting

* fix: Update dependencies in Conversations component to include marketplace visibility

Modified the useEffect dependency array in the Conversations component to include `showAgentMarketplace`, ensuring proper reactivity to changes in marketplace visibility. Additionally, updated the test suite to mock favorites state for accurate height caching validation when marketplace visibility changes.
This commit is contained in:
Danny Avila 2026-03-23 13:33:26 -04:00 committed by GitHub
parent ccd049d8ce
commit 5a373825a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 234 additions and 35 deletions

View file

@ -35,3 +35,11 @@ export function useActivePanel() {
}
return context;
}
/** Returns `active` when it matches a known link, otherwise the first link's id. */
export function resolveActivePanel(active: string, links: { id: string }[]): string {
if (links.length > 0 && links.some((l) => l.id === active)) {
return active;
}
return links[0]?.id ?? active;
}

View file

@ -1,7 +1,11 @@
import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { ActivePanelProvider, useActivePanel } from '~/Providers/ActivePanelContext';
import {
ActivePanelProvider,
resolveActivePanel,
useActivePanel,
} from '~/Providers/ActivePanelContext';
const STORAGE_KEY = 'side:active-panel';
@ -58,3 +62,23 @@ describe('ActivePanelContext', () => {
spy.mockRestore();
});
});
describe('resolveActivePanel', () => {
const links = [{ id: 'conversations' }, { id: 'prompts' }, { id: 'files' }];
it('returns active when it matches a link', () => {
expect(resolveActivePanel('prompts', links)).toBe('prompts');
});
it('falls back to first link when active does not match', () => {
expect(resolveActivePanel('hide-panel', links)).toBe('conversations');
});
it('returns active unchanged when links is empty', () => {
expect(resolveActivePanel('agents', [])).toBe('agents');
});
it('falls back to the only link when active is stale', () => {
expect(resolveActivePanel('agents', [{ id: 'conversations' }])).toBe('conversations');
});
});

View file

@ -7,11 +7,10 @@ import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/
import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
import MarketplaceAdminSettings from './MarketplaceAdminSettings';
import { SidePanelGroup } from '~/components/SidePanel';
import { NewChat } from '~/components/Nav';
import { cn } from '~/utils';
import CategoryTabs from './CategoryTabs';
import SearchBar from './SearchBar';
import AgentGrid from './AgentGrid';
import { cn } from '~/utils';
interface AgentMarketplaceProps {
className?: string;
@ -202,12 +201,6 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
ref={scrollContainerRef}
className="scrollbar-gutter-stable relative flex h-full flex-col overflow-y-auto overflow-x-hidden"
>
{/* Simplified header for agents marketplace - only show nav controls when needed */}
{!isSmallScreen && (
<div className="sticky top-0 z-20 flex items-center justify-between bg-surface-secondary p-2 font-semibold text-text-primary md:h-14">
<NewChat className="border border-border-light bg-surface-secondary p-2" />
</div>
)}
{/* Hero Section - scrolls away */}
{!isSmallScreen && (
<div className="container mx-auto max-w-4xl">
@ -222,9 +215,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
</div>
)}
{/* Sticky wrapper for search bar and categories */}
<div
className={cn('sticky z-10 bg-presentation pb-4', isSmallScreen ? 'top-0' : 'top-14')}
>
<div className="sticky top-0 z-10 bg-presentation pb-4">
<div className="container mx-auto max-w-4xl px-4">
{/* Search bar */}
<div className="mx-auto flex max-w-2xl gap-2 pb-6">

View file

@ -168,6 +168,8 @@ const Conversations: FC<ConversationsProps> = ({
const convoHeight = isSmallScreen ? 44 : 34;
const showAgentMarketplace = useShowMarketplace();
const favoritesContentKeyRef = useRef('');
// Fetch active job IDs for showing generation indicators
const { data: activeJobsData } = useActiveJobs();
const activeJobIds = useMemo(
@ -179,6 +181,8 @@ const Conversations: FC<ConversationsProps> = ({
const shouldShowFavorites =
!search.query && (isFavoritesLoading || favorites.length > 0 || showAgentMarketplace);
favoritesContentKeyRef.current = `${favorites.length}-${showAgentMarketplace ? 1 : 0}-${isFavoritesLoading ? 1 : 0}`;
const filteredConversations = useMemo(
() => rawConversations.filter(Boolean) as TConversation[],
[rawConversations],
@ -226,7 +230,7 @@ const Conversations: FC<ConversationsProps> = ({
return `unknown-${index}`;
}
if (item.type === 'favorites') {
return 'favorites';
return `favorites-${favoritesContentKeyRef.current}`;
}
if (item.type === 'chats-header') {
return 'chats-header';
@ -246,7 +250,6 @@ const Conversations: FC<ConversationsProps> = ({
[convoHeight],
);
// Debounced function to clear cache and recompute heights
const clearFavoritesCache = useCallback(() => {
if (cache) {
cache.clear(0, 0);
@ -256,13 +259,12 @@ const Conversations: FC<ConversationsProps> = ({
}
}, [cache, containerRef]);
// Clear cache when favorites change
useEffect(() => {
const frameId = requestAnimationFrame(() => {
clearFavoritesCache();
});
return () => cancelAnimationFrame(frameId);
}, [favorites.length, isFavoritesLoading, clearFavoritesCache]);
}, [favorites.length, isFavoritesLoading, showAgentMarketplace, clearFavoritesCache]);
const rowRenderer = useCallback(
({ index, key, parent, style }) => {
@ -280,11 +282,7 @@ const Conversations: FC<ConversationsProps> = ({
if (item.type === 'favorites') {
return (
<MeasuredRow key={key} {...rowProps}>
<FavoritesList
isSmallScreen={isSmallScreen}
toggleNav={toggleNav}
onHeightChange={clearFavoritesCache}
/>
<FavoritesList isSmallScreen={isSmallScreen} toggleNav={toggleNav} />
</MeasuredRow>
);
}
@ -333,7 +331,6 @@ const Conversations: FC<ConversationsProps> = ({
flattenedItems,
moveToTop,
toggleNav,
clearFavoritesCache,
isSmallScreen,
isChatsExpanded,
setIsChatsExpanded,

View file

@ -0,0 +1,185 @@
import React, { createRef } from 'react';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import { RecoilRoot } from 'recoil';
import type { CellMeasurerCache, List } from 'react-virtualized';
let mockCapturedCache: CellMeasurerCache | null = null;
jest.mock('react-virtualized', () => {
const actual = jest.requireActual('react-virtualized');
return {
...actual,
AutoSizer: ({
children,
}: {
children: (size: { width: number; height: number }) => React.ReactNode;
}) => children({ width: 300, height: 600 }),
CellMeasurer: ({
children,
}: {
children: (opts: { registerChild: () => void }) => React.ReactNode;
}) => children({ registerChild: () => {} }),
List: ({
rowRenderer,
rowCount,
deferredMeasurementCache,
}: {
rowRenderer: (opts: {
index: number;
key: string;
style: object;
parent: object;
}) => React.ReactNode;
rowCount: number;
deferredMeasurementCache: CellMeasurerCache;
[key: string]: unknown;
}) => {
mockCapturedCache = deferredMeasurementCache;
return (
<div data-testid="virtual-list" data-row-count={rowCount}>
{Array.from({ length: Math.min(rowCount, 10) }, (_, i) =>
rowRenderer({ index: i, key: `row-${i}`, style: {}, parent: {} }),
)}
</div>
);
},
};
});
jest.mock('~/store', () => {
const { atom } = jest.requireActual('recoil');
return {
__esModule: true,
default: {
search: atom({ key: 'test-conversations-search', default: { query: '' } }),
},
};
});
type FavoriteEntry = { agentId?: string; model?: string; endpoint?: string };
const mockFavoritesState: { favorites: FavoriteEntry[]; isLoading: boolean } = {
favorites: [],
isLoading: false,
};
let mockShowMarketplace = true;
jest.mock('~/hooks', () => ({
useFavorites: () => mockFavoritesState,
useLocalize: () => (key: string) => key,
useShowMarketplace: () => mockShowMarketplace,
TranslationKeys: {},
}));
jest.mock('@librechat/client', () => ({
Spinner: () => <div data-testid="spinner" />,
useMediaQuery: () => false,
}));
jest.mock('~/data-provider', () => ({
useActiveJobs: () => ({ data: undefined }),
}));
jest.mock('~/utils', () => ({
groupConversationsByDate: () => [],
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}));
jest.mock('~/components/Nav/Favorites/FavoritesList', () => ({
__esModule: true,
default: () => <div data-testid="favorites-list" />,
}));
jest.mock('../Convo', () => ({
__esModule: true,
default: () => <div data-testid="convo" />,
}));
import Conversations from '../Conversations';
describe('Conversations favorites CellMeasurerCache key invalidation', () => {
const containerRef = createRef<List>();
beforeEach(() => {
mockCapturedCache = null;
mockFavoritesState.favorites = [];
mockFavoritesState.isLoading = false;
mockShowMarketplace = true;
});
const Wrapper = () => (
<RecoilRoot>
<Conversations
conversations={[]}
moveToTop={jest.fn()}
toggleNav={jest.fn()}
containerRef={containerRef}
loadMoreConversations={jest.fn()}
isLoading={false}
isSearchLoading={false}
isChatsExpanded={true}
setIsChatsExpanded={jest.fn()}
/>
</RecoilRoot>
);
it('should invalidate the cached favorites height when favorites count changes', () => {
const { rerender } = render(<Wrapper />);
const cache = mockCapturedCache!;
expect(cache).toBeDefined();
cache.set(0, 0, 300, 48);
expect(cache.has(0, 0)).toBe(true);
expect(cache.getHeight(0, 0)).toBe(48);
mockFavoritesState.favorites = [{ model: 'gpt-4', endpoint: 'openAI' }];
rerender(<Wrapper />);
expect(cache.has(0, 0)).toBe(false);
});
it('should invalidate the cached favorites height when loading state transitions', () => {
mockFavoritesState.isLoading = true;
const { rerender } = render(<Wrapper />);
const cache = mockCapturedCache!;
cache.set(0, 0, 300, 80);
expect(cache.has(0, 0)).toBe(true);
mockFavoritesState.isLoading = false;
rerender(<Wrapper />);
expect(cache.has(0, 0)).toBe(false);
});
it('should invalidate the cached favorites height when marketplace visibility changes', () => {
mockFavoritesState.favorites = [{ model: 'gpt-4', endpoint: 'openAI' }];
const { rerender } = render(<Wrapper />);
const cache = mockCapturedCache!;
cache.set(0, 0, 300, 48);
expect(cache.has(0, 0)).toBe(true);
mockShowMarketplace = false;
rerender(<Wrapper />);
expect(cache.has(0, 0)).toBe(false);
});
it('should retain the cached favorites height when content state is unchanged', () => {
mockFavoritesState.favorites = [{ model: 'gpt-4', endpoint: 'openAI' }];
const { rerender } = render(<Wrapper />);
const cache = mockCapturedCache!;
cache.set(0, 0, 300, 88);
expect(cache.has(0, 0)).toBe(true);
expect(cache.getHeight(0, 0)).toBe(88);
rerender(<Wrapper />);
expect(cache.has(0, 0)).toBe(true);
expect(cache.getHeight(0, 0)).toBe(88);
});
});

View file

@ -21,6 +21,7 @@ import { useGetEndpointsQuery } from '~/data-provider';
import FavoriteItem from './FavoriteItem';
import store from '~/store';
/** Height intentionally matches FavoriteItem (px-3 py-2 + h-5 icon) to keep the CellMeasurerCache valid across the isAgentsLoading transition. */
const FavoriteItemSkeleton = () => (
<div className="flex w-full items-center rounded-lg px-3 py-2">
<Skeleton className="mr-2 h-5 w-5 rounded-full" />
@ -118,12 +119,9 @@ const DraggableFavoriteItem = ({
export default function FavoritesList({
isSmallScreen,
toggleNav,
onHeightChange,
}: {
isSmallScreen?: boolean;
toggleNav?: () => void;
/** Callback when the list height might have changed (e.g., agents finished loading) */
onHeightChange?: () => void;
}) {
const navigate = useNavigate();
const localize = useLocalize();
@ -267,12 +265,6 @@ export default function FavoritesList({
(allAgentIds.length > 0 && agentsMap === undefined) ||
(missingAgentIds.length > 0 && missingAgentQueries.some((q) => q.isLoading));
useEffect(() => {
if (!isAgentsLoading && onHeightChange) {
onHeightChange();
}
}, [isAgentsLoading, onHeightChange]);
const draggedFavoritesRef = useRef(safeFavorites);
const moveItem = useCallback(

View file

@ -1,12 +1,13 @@
import type { NavLink } from '~/common';
import { useActivePanel } from '~/Providers';
import { useActivePanel, resolveActivePanel } from '~/Providers';
export default function Nav({ links }: { links: NavLink[] }) {
const { active } = useActivePanel();
const effectiveActive = resolveActivePanel(active, links);
return (
<div className="flex h-full min-h-0 flex-col overflow-y-auto overflow-x-hidden pt-2 text-text-primary">
{links.map((link) =>
link.id === active && link.Component ? <link.Component key={link.id} /> : null,
link.id === effectiveActive && link.Component ? <link.Component key={link.id} /> : null,
)}
</div>
);

View file

@ -5,7 +5,7 @@ import { QueryKeys } from 'librechat-data-provider';
import { Skeleton, Sidebar, Button, TooltipAnchor, NewChatIcon } from '@librechat/client';
import type { NavLink } from '~/common';
import { CLOSE_SIDEBAR_ID } from '~/components/Chat/Menus/OpenSidebar';
import { useActivePanel } from '~/Providers';
import { useActivePanel, resolveActivePanel } from '~/Providers';
import { useLocalize, useNewConvo } from '~/hooks';
import { clearMessagesCache, cn } from '~/utils';
import store from '~/store';
@ -119,6 +119,7 @@ function ExpandedPanel({
}) {
const localize = useLocalize();
const { active, setActive } = useActivePanel();
const effectiveActive = resolveActivePanel(active, links);
const toggleLabel = expanded ? 'com_nav_close_sidebar' : 'com_nav_open_sidebar';
const toggleClick = expanded ? onCollapse : onExpand;
@ -149,7 +150,7 @@ function ExpandedPanel({
<NavIconButton
key={link.id}
link={link}
isActive={link.id === active}
isActive={link.id === effectiveActive}
expanded={expanded ?? true}
setActive={setActive}
onExpand={onExpand}