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({