From 5a373825a5d451c1392a350a3bf292f43d7b8a87 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 23 Mar 2026 13:33:26 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=90=20style:=20Resolve=20Stale=20Activ?= =?UTF-8?q?e=20Sidebar=20Panel=20and=20Favorites=20Row=20Height=20(#12366)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛 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. --- client/src/Providers/ActivePanelContext.tsx | 8 + .../__tests__/ActivePanelContext.spec.tsx | 26 ++- client/src/components/Agents/Marketplace.tsx | 13 +- .../Conversations/Conversations.tsx | 17 +- .../__tests__/Conversations.test.tsx | 185 ++++++++++++++++++ .../Nav/Favorites/FavoritesList.tsx | 10 +- client/src/components/SidePanel/Nav.tsx | 5 +- .../UnifiedSidebar/ExpandedPanel.tsx | 5 +- 8 files changed, 234 insertions(+), 35 deletions(-) create mode 100644 client/src/components/Conversations/__tests__/Conversations.test.tsx diff --git a/client/src/Providers/ActivePanelContext.tsx b/client/src/Providers/ActivePanelContext.tsx index 9d6082d4e4..46b2a189b7 100644 --- a/client/src/Providers/ActivePanelContext.tsx +++ b/client/src/Providers/ActivePanelContext.tsx @@ -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; +} diff --git a/client/src/Providers/__tests__/ActivePanelContext.spec.tsx b/client/src/Providers/__tests__/ActivePanelContext.spec.tsx index 6a6059c9b4..0f2f89e8f7 100644 --- a/client/src/Providers/__tests__/ActivePanelContext.spec.tsx +++ b/client/src/Providers/__tests__/ActivePanelContext.spec.tsx @@ -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'); + }); +}); diff --git a/client/src/components/Agents/Marketplace.tsx b/client/src/components/Agents/Marketplace.tsx index 0c9c9fb4cc..816705a0db 100644 --- a/client/src/components/Agents/Marketplace.tsx +++ b/client/src/components/Agents/Marketplace.tsx @@ -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 = ({ 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 && ( -
- -
- )} {/* Hero Section - scrolls away */} {!isSmallScreen && (
@@ -222,9 +215,7 @@ const AgentMarketplace: React.FC = ({ className = '' }) =
)} {/* Sticky wrapper for search bar and categories */} -
+
{/* Search bar */}
diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index fee9e4fa82..7e7eea4812 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -168,6 +168,8 @@ const Conversations: FC = ({ 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 = ({ 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 = ({ 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 = ({ [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 = ({ } }, [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 = ({ if (item.type === 'favorites') { return ( - + ); } @@ -333,7 +331,6 @@ const Conversations: FC = ({ flattenedItems, moveToTop, toggleNav, - clearFavoritesCache, isSmallScreen, isChatsExpanded, setIsChatsExpanded, diff --git a/client/src/components/Conversations/__tests__/Conversations.test.tsx b/client/src/components/Conversations/__tests__/Conversations.test.tsx new file mode 100644 index 0000000000..a4f403fb7f --- /dev/null +++ b/client/src/components/Conversations/__tests__/Conversations.test.tsx @@ -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 ( +
+ {Array.from({ length: Math.min(rowCount, 10) }, (_, i) => + rowRenderer({ index: i, key: `row-${i}`, style: {}, parent: {} }), + )} +
+ ); + }, + }; +}); + +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: () =>
, + 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: () =>
, +})); + +jest.mock('../Convo', () => ({ + __esModule: true, + default: () =>
, +})); + +import Conversations from '../Conversations'; + +describe('Conversations – favorites CellMeasurerCache key invalidation', () => { + const containerRef = createRef(); + + beforeEach(() => { + mockCapturedCache = null; + mockFavoritesState.favorites = []; + mockFavoritesState.isLoading = false; + mockShowMarketplace = true; + }); + + const Wrapper = () => ( + + + + ); + + it('should invalidate the cached favorites height when favorites count changes', () => { + const { rerender } = render(); + 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(); + + expect(cache.has(0, 0)).toBe(false); + }); + + it('should invalidate the cached favorites height when loading state transitions', () => { + mockFavoritesState.isLoading = true; + const { rerender } = render(); + const cache = mockCapturedCache!; + + cache.set(0, 0, 300, 80); + expect(cache.has(0, 0)).toBe(true); + + mockFavoritesState.isLoading = false; + rerender(); + + 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(); + const cache = mockCapturedCache!; + + cache.set(0, 0, 300, 48); + expect(cache.has(0, 0)).toBe(true); + + mockShowMarketplace = false; + rerender(); + + 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(); + const cache = mockCapturedCache!; + + cache.set(0, 0, 300, 88); + expect(cache.has(0, 0)).toBe(true); + expect(cache.getHeight(0, 0)).toBe(88); + + rerender(); + + expect(cache.has(0, 0)).toBe(true); + expect(cache.getHeight(0, 0)).toBe(88); + }); +}); diff --git a/client/src/components/Nav/Favorites/FavoritesList.tsx b/client/src/components/Nav/Favorites/FavoritesList.tsx index e47e01fb87..934652349f 100644 --- a/client/src/components/Nav/Favorites/FavoritesList.tsx +++ b/client/src/components/Nav/Favorites/FavoritesList.tsx @@ -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 = () => (
@@ -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( diff --git a/client/src/components/SidePanel/Nav.tsx b/client/src/components/SidePanel/Nav.tsx index 5db5b3f5bb..903b32f49b 100644 --- a/client/src/components/SidePanel/Nav.tsx +++ b/client/src/components/SidePanel/Nav.tsx @@ -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 (
{links.map((link) => - link.id === active && link.Component ? : null, + link.id === effectiveActive && link.Component ? : null, )}
); diff --git a/client/src/components/UnifiedSidebar/ExpandedPanel.tsx b/client/src/components/UnifiedSidebar/ExpandedPanel.tsx index 79adbf80a7..74b08a6a29 100644 --- a/client/src/components/UnifiedSidebar/ExpandedPanel.tsx +++ b/client/src/components/UnifiedSidebar/ExpandedPanel.tsx @@ -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({