diff --git a/.husky/pre-commit b/.husky/pre-commit index 23c736d1de..70fef90065 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,3 @@ +#!/bin/sh [ -n "$CI" ] && exit 0 npx lint-staged --config ./.husky/lint-staged.config.js diff --git a/client/src/Providers/ActivePanelContext.tsx b/client/src/Providers/ActivePanelContext.tsx index 4a8d6ccfc4..9d6082d4e4 100644 --- a/client/src/Providers/ActivePanelContext.tsx +++ b/client/src/Providers/ActivePanelContext.tsx @@ -1,31 +1,31 @@ -import { createContext, useContext, useState, ReactNode } from 'react'; +import { createContext, useCallback, useContext, useMemo, useState, ReactNode } from 'react'; + +const STORAGE_KEY = 'side:active-panel'; +const DEFAULT_PANEL = 'conversations'; + +function getInitialActivePanel(): string { + const saved = localStorage.getItem(STORAGE_KEY); + return saved ? saved : DEFAULT_PANEL; +} interface ActivePanelContextType { - active: string | undefined; + active: string; setActive: (id: string) => void; } const ActivePanelContext = createContext(undefined); -export function ActivePanelProvider({ - children, - defaultActive, -}: { - children: ReactNode; - defaultActive?: string; -}) { - const [active, _setActive] = useState(defaultActive); +export function ActivePanelProvider({ children }: { children: ReactNode }) { + const [active, _setActive] = useState(getInitialActivePanel); - const setActive = (id: string) => { - localStorage.setItem('side:active-panel', id); + const setActive = useCallback((id: string) => { + localStorage.setItem(STORAGE_KEY, id); _setActive(id); - }; + }, []); - return ( - - {children} - - ); + const value = useMemo(() => ({ active, setActive }), [active, setActive]); + + return {children}; } export function useActivePanel() { diff --git a/client/src/Providers/ChatContext.tsx b/client/src/Providers/ChatContext.tsx index 3d3acbcc42..8af75f90c0 100644 --- a/client/src/Providers/ChatContext.tsx +++ b/client/src/Providers/ChatContext.tsx @@ -2,5 +2,11 @@ import { createContext, useContext } from 'react'; import useChatHelpers from '~/hooks/Chat/useChatHelpers'; type TChatContext = ReturnType; -export const ChatContext = createContext({} as TChatContext); -export const useChatContext = () => useContext(ChatContext); +export const ChatContext = createContext(null); +export const useChatContext = () => { + const ctx = useContext(ChatContext); + if (!ctx) { + throw new Error('useChatContext must be used within a ChatContext.Provider'); + } + return ctx; +}; diff --git a/client/src/Providers/SidePanelContext.tsx b/client/src/Providers/SidePanelContext.tsx deleted file mode 100644 index 3ce7834ccc..0000000000 --- a/client/src/Providers/SidePanelContext.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { createContext, useContext, useMemo } from 'react'; -import type { EModelEndpoint } from 'librechat-data-provider'; -import { useChatContext } from './ChatContext'; - -interface SidePanelContextValue { - endpoint?: EModelEndpoint | null; -} - -const SidePanelContext = createContext(undefined); - -export function SidePanelProvider({ children }: { children: React.ReactNode }) { - const { conversation } = useChatContext(); - - /** Context value only created when endpoint changes */ - const contextValue = useMemo( - () => ({ - endpoint: conversation?.endpoint, - }), - [conversation?.endpoint], - ); - - return {children}; -} - -export function useSidePanelContext() { - const context = useContext(SidePanelContext); - if (!context) { - throw new Error('useSidePanelContext must be used within SidePanelProvider'); - } - return context; -} diff --git a/client/src/Providers/__tests__/ActivePanelContext.spec.tsx b/client/src/Providers/__tests__/ActivePanelContext.spec.tsx new file mode 100644 index 0000000000..6a6059c9b4 --- /dev/null +++ b/client/src/Providers/__tests__/ActivePanelContext.spec.tsx @@ -0,0 +1,60 @@ +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'; + +const STORAGE_KEY = 'side:active-panel'; + +function TestConsumer() { + const { active, setActive } = useActivePanel(); + return ( +
+ {active} +
+ ); +} + +describe('ActivePanelContext', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('defaults to conversations when no localStorage value exists', () => { + render( + + + , + ); + expect(screen.getByTestId('active')).toHaveTextContent('conversations'); + }); + + it('reads initial value from localStorage', () => { + localStorage.setItem(STORAGE_KEY, 'memories'); + render( + + + , + ); + expect(screen.getByTestId('active')).toHaveTextContent('memories'); + }); + + it('setActive updates state and writes to localStorage', () => { + render( + + + , + ); + fireEvent.click(screen.getByTestId('switch-btn')); + expect(screen.getByTestId('active')).toHaveTextContent('bookmarks'); + expect(localStorage.getItem(STORAGE_KEY)).toBe('bookmarks'); + }); + + it('throws when useActivePanel is called outside provider', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + 'useActivePanel must be used within an ActivePanelProvider', + ); + spy.mockRestore(); + }); +}); diff --git a/client/src/Providers/__tests__/ChatContext.spec.tsx b/client/src/Providers/__tests__/ChatContext.spec.tsx new file mode 100644 index 0000000000..0ed00bf580 --- /dev/null +++ b/client/src/Providers/__tests__/ChatContext.spec.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import { ChatContext, useChatContext } from '~/Providers/ChatContext'; + +function TestConsumer() { + const ctx = useChatContext(); + return {ctx.index}; +} + +describe('ChatContext', () => { + it('throws when useChatContext is called outside a provider', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + 'useChatContext must be used within a ChatContext.Provider', + ); + spy.mockRestore(); + }); + + it('provides context value when wrapped in provider', () => { + const mockHelpers = { index: 0 } as ReturnType< + typeof import('~/hooks/Chat/useChatHelpers').default + >; + render( + + + , + ); + expect(screen.getByTestId('index')).toHaveTextContent('0'); + }); +}); diff --git a/client/src/Providers/index.ts b/client/src/Providers/index.ts index 43a16fa976..3ae90e189c 100644 --- a/client/src/Providers/index.ts +++ b/client/src/Providers/index.ts @@ -22,7 +22,6 @@ export * from './ToolCallsMapContext'; export * from './SetConvoContext'; export * from './SearchContext'; export * from './BadgeRowContext'; -export * from './SidePanelContext'; export * from './DragDropContext'; export * from './ArtifactsContext'; export * from './PromptGroupsContext'; diff --git a/client/src/common/types.ts b/client/src/common/types.ts index d47ff02bd8..85044bb2bc 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -132,13 +132,6 @@ export type NavLink = { id: string; }; -export interface NavProps { - isCollapsed: boolean; - links: NavLink[]; - resize?: (size: number) => void; - defaultActive?: string; -} - export interface DataColumnMeta { meta: | { @@ -561,11 +554,6 @@ export interface ModelItemProps { className?: string; } -export type ContextType = { - navVisible: boolean; - setNavVisible: React.Dispatch>; -}; - export interface SwitcherProps { endpoint?: t.EModelEndpoint | null; endpointKeyProvided: boolean; diff --git a/client/src/components/Agents/Marketplace.tsx b/client/src/components/Agents/Marketplace.tsx index 69db9fc630..0c9c9fb4cc 100644 --- a/client/src/components/Agents/Marketplace.tsx +++ b/client/src/components/Agents/Marketplace.tsx @@ -1,23 +1,17 @@ import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; -import { useRecoilState } from 'recoil'; -import { useOutletContext } from 'react-router-dom'; -import { useQueryClient } from '@tanstack/react-query'; import { useSearchParams, useParams, useNavigate } from 'react-router-dom'; -import { TooltipAnchor, Button, NewChatIcon, useMediaQuery } from '@librechat/client'; -import { PermissionTypes, Permissions, QueryKeys } from 'librechat-data-provider'; +import { useMediaQuery } from '@librechat/client'; +import { PermissionTypes, Permissions } from 'librechat-data-provider'; import type t from 'librechat-data-provider'; -import type { ContextType } from '~/common'; import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/hooks'; import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider'; import MarketplaceAdminSettings from './MarketplaceAdminSettings'; -import { SidePanelProvider, useChatContext } from '~/Providers'; import { SidePanelGroup } from '~/components/SidePanel'; -import { OpenSidebar } from '~/components/Chat/Menus'; -import { cn, clearMessagesCache } from '~/utils'; +import { NewChat } from '~/components/Nav'; +import { cn } from '~/utils'; import CategoryTabs from './CategoryTabs'; import SearchBar from './SearchBar'; import AgentGrid from './AgentGrid'; -import store from '~/store'; interface AgentMarketplaceProps { className?: string; @@ -34,13 +28,9 @@ const AgentMarketplace: React.FC = ({ className = '' }) = const localize = useLocalize(); const navigate = useNavigate(); const { category } = useParams(); - const queryClient = useQueryClient(); const [searchParams, setSearchParams] = useSearchParams(); - const { conversation, newConversation } = useChatContext(); const isSmallScreen = useMediaQuery('(max-width: 768px)'); - const { navVisible, setNavVisible } = useOutletContext(); - const [hideSidePanel, setHideSidePanel] = useRecoilState(store.hideSidePanel); // Get URL parameters const searchQuery = searchParams.get('q') || ''; @@ -59,15 +49,6 @@ const AgentMarketplace: React.FC = ({ className = '' }) = // Set page title useDocumentTitle(`${localize('com_agents_marketplace')} | LibreChat`); - // Ensure right sidebar is always visible in marketplace - useEffect(() => { - setHideSidePanel(false); - - // Also try to force expand via localStorage - localStorage.setItem('hideSidePanel', 'false'); - localStorage.setItem('fullPanelCollapse', 'false'); - }, [setHideSidePanel, hideSidePanel]); - // Ensure endpoints config is loaded first (required for agent queries) useGetEndpointsQuery(); @@ -193,33 +174,6 @@ const AgentMarketplace: React.FC = ({ className = '' }) = } }; - /** - * Handle new chat button click - */ - - const handleNewChat = (e: React.MouseEvent) => { - if (e.button === 0 && (e.ctrlKey || e.metaKey)) { - window.open('/c/new', '_blank'); - return; - } - clearMessagesCache(queryClient, conversation?.conversationId); - queryClient.invalidateQueries([QueryKeys.messages]); - newConversation(); - }; - - // Layout configuration for SidePanelGroup - const defaultLayout = useMemo(() => { - const resizableLayout = localStorage.getItem('react-resizable-panels:layout'); - return typeof resizableLayout === 'string' ? JSON.parse(resizableLayout) : undefined; - }, []); - - const defaultCollapsed = useMemo(() => { - const collapsedPanels = localStorage.getItem('react-resizable-panels:collapsed'); - return typeof collapsedPanels === 'string' ? JSON.parse(collapsedPanels) : true; - }, []); - - const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []); - const hasAccessToMarketplace = useHasAccess({ permissionType: PermissionTypes.MARKETPLACE, permission: Permissions.USE, @@ -241,99 +195,144 @@ const AgentMarketplace: React.FC = ({ className = '' }) = } return (
- - -
- {/* Scrollable container */} -
- {/* Simplified header for agents marketplace - only show nav controls when needed */} - {!isSmallScreen && ( -
-
- {!navVisible ? ( - <> - - - - - } - /> - - ) : ( - // Invisible placeholder to maintain height -
- )} -
-
- )} - {/* Hero Section - scrolls away */} - {!isSmallScreen && ( -
-
-

- {localize('com_agents_marketplace')} -

-

- {localize('com_agents_marketplace_subtitle')} -

-
-
- )} - {/* Sticky wrapper for search bar and categories */} -
-
- {/* Search bar */} -
- - {/* TODO: Remove this once we have a better way to handle admin settings */} - {/* Admin Settings */} - -
- - {/* Category tabs */} - + +
+ {/* Scrollable container */} +
+ {/* Simplified header for agents marketplace - only show nav controls when needed */} + {!isSmallScreen && ( +
+ +
+ )} + {/* Hero Section - scrolls away */} + {!isSmallScreen && ( +
+
+

+ {localize('com_agents_marketplace')} +

+

+ {localize('com_agents_marketplace_subtitle')} +

- {/* Scrollable content area */} -
- {/* Two-pane animated container wrapping category header + grid */} -
- {/* Current content pane */} + )} + {/* Sticky wrapper for search bar and categories */} +
+
+ {/* Search bar */} +
+ + {/* TODO: Remove this once we have a better way to handle admin settings */} + {/* Admin Settings */} + +
+ + {/* Category tabs */} + +
+
+ {/* Scrollable content area */} +
+ {/* Two-pane animated container wrapping category header + grid */} +
+ {/* Current content pane */} +
+ {/* Category header - only show when not searching */} + {!searchQuery && ( +
+ {(() => { + // Get category data for display + const getCategoryData = () => { + if (displayCategory === 'promoted') { + return { + name: localize('com_agents_top_picks'), + description: localize('com_agents_recommended'), + }; + } + if (displayCategory === 'all') { + return { + name: localize('com_agents_all'), + description: localize('com_agents_all_description'), + }; + } + + // Find the category in the API data + const categoryData = categoriesQuery.data?.find( + (cat) => cat.value === displayCategory, + ); + if (categoryData) { + return { + name: categoryData.label?.startsWith('com_') + ? localize(categoryData.label as TranslationKeys) + : categoryData.label, + description: categoryData.description?.startsWith('com_') + ? localize(categoryData.description as TranslationKeys) + : categoryData.description || '', + }; + } + + // Fallback for unknown categories + return { + name: + displayCategory.charAt(0).toUpperCase() + displayCategory.slice(1), + description: '', + }; + }; + + const { name, description } = getCategoryData(); + + return ( +
+

{name}

+ {description && ( +

{description}

+ )} +
+ ); + })()} +
+ )} + + {/* Agent grid */} + +
+ + {/* Next content pane, only during transition */} + {isTransitioning && nextCategory && (
{/* Category header - only show when not searching */} {!searchQuery && ( @@ -341,13 +340,13 @@ const AgentMarketplace: React.FC = ({ className = '' }) = {(() => { // Get category data for display const getCategoryData = () => { - if (displayCategory === 'promoted') { + if (nextCategory === 'promoted') { return { name: localize('com_agents_top_picks'), description: localize('com_agents_recommended'), }; } - if (displayCategory === 'all') { + if (nextCategory === 'all') { return { name: localize('com_agents_all'), description: localize('com_agents_all_description'), @@ -356,7 +355,7 @@ const AgentMarketplace: React.FC = ({ className = '' }) = // Find the category in the API data const categoryData = categoriesQuery.data?.find( - (cat) => cat.value === displayCategory, + (cat) => cat.value === nextCategory, ); if (categoryData) { return { @@ -364,7 +363,9 @@ const AgentMarketplace: React.FC = ({ className = '' }) = ? localize(categoryData.label as TranslationKeys) : categoryData.label, description: categoryData.description?.startsWith('com_') - ? localize(categoryData.description as TranslationKeys) + ? localize( + categoryData.description as Parameters[0], + ) : categoryData.description || '', }; } @@ -372,7 +373,8 @@ const AgentMarketplace: React.FC = ({ className = '' }) = // Fallback for unknown categories return { name: - displayCategory.charAt(0).toUpperCase() + displayCategory.slice(1), + (nextCategory || '').charAt(0).toUpperCase() + + (nextCategory || '').slice(1), description: '', }; }; @@ -393,102 +395,21 @@ const AgentMarketplace: React.FC = ({ className = '' }) = {/* Agent grid */}
+ )} - {/* Next content pane, only during transition */} - {isTransitioning && nextCategory && ( -
- {/* Category header - only show when not searching */} - {!searchQuery && ( -
- {(() => { - // Get category data for display - const getCategoryData = () => { - if (nextCategory === 'promoted') { - return { - name: localize('com_agents_top_picks'), - description: localize('com_agents_recommended'), - }; - } - if (nextCategory === 'all') { - return { - name: localize('com_agents_all'), - description: localize('com_agents_all_description'), - }; - } - - // Find the category in the API data - const categoryData = categoriesQuery.data?.find( - (cat) => cat.value === nextCategory, - ); - if (categoryData) { - return { - name: categoryData.label?.startsWith('com_') - ? localize(categoryData.label as TranslationKeys) - : categoryData.label, - description: categoryData.description?.startsWith('com_') - ? localize( - categoryData.description as Parameters[0], - ) - : categoryData.description || '', - }; - } - - // Fallback for unknown categories - return { - name: - (nextCategory || '').charAt(0).toUpperCase() + - (nextCategory || '').slice(1), - description: '', - }; - }; - - const { name, description } = getCategoryData(); - - return ( -
-

{name}

- {description && ( -

{description}

- )} -
- ); - })()} -
- )} - - {/* Agent grid */} - -
- )} - - {/* Note: Using Tailwind keyframes for slide in/out animations */} -
+ {/* Note: Using Tailwind keyframes for slide in/out animations */}
-
-
- +
+
+
); }; diff --git a/client/src/components/Agents/MarketplaceContext.tsx b/client/src/components/Agents/MarketplaceContext.tsx index 09c88e3291..9193cbb82b 100644 --- a/client/src/components/Agents/MarketplaceContext.tsx +++ b/client/src/components/Agents/MarketplaceContext.tsx @@ -13,5 +13,5 @@ interface MarketplaceProviderProps { export const MarketplaceProvider: React.FC = ({ children }) => { const chatHelpers = useChatHelpers(0, 'new'); - return {children}; + return {children}; }; diff --git a/client/src/components/Chat/AddMultiConvo.tsx b/client/src/components/Chat/AddMultiConvo.tsx index 48e9919092..101dbadd19 100644 --- a/client/src/components/Chat/AddMultiConvo.tsx +++ b/client/src/components/Chat/AddMultiConvo.tsx @@ -44,9 +44,9 @@ function AddMultiConvo() { aria-label={localize('com_ui_add_multi_conversation')} onClick={clickHandler} data-testid="add-multi-convo-button" - className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-presentation text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary" + className="inline-flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-presentation text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary" > -