From 7b4820390640ba48c656e8b726a6692723db195c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 13 Apr 2026 09:46:38 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=82=EF=B8=8F=20feat:=20Sidebar=20Icon?= =?UTF-8?q?=20Toggle=20&=20New=20Chat=20History=20Switch=20(#12642)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🗂️ feat: Sidebar Icon Toggle & New Chat History Switch Add collapse-on-active-click for sidebar icons (VSCode-style) and optionally switch to Chat History panel when creating a new chat. * fix: Address review findings — extract DEFAULT_PANEL constant, add tests Export DEFAULT_PANEL from ActivePanelContext and use it in ExpandedPanel instead of hardcoding 'conversations'. Add ExpandedPanel tests covering NavIconButton collapse toggle and NewChatButton panel switch behaviors. * fix: Address review — prop-drill setActive, test disabled setting, strengthen assertions Pass setActive as a prop to NewChatButton instead of subscribing to ActivePanelContext, avoiding wasted re-renders on every panel switch. Add negative-path test for switchToHistory=false. Add positive panel assertions to inactive-icon click tests. Fix import order. --- client/src/Providers/ActivePanelContext.tsx | 2 +- .../Nav/SettingsTabs/General/General.tsx | 7 + .../UnifiedSidebar/ExpandedPanel.tsx | 25 ++- .../__tests__/ExpandedPanel.spec.tsx | 167 ++++++++++++++++++ client/src/locales/en/translation.json | 1 + client/src/store/settings.ts | 1 + 6 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 client/src/components/UnifiedSidebar/__tests__/ExpandedPanel.spec.tsx diff --git a/client/src/Providers/ActivePanelContext.tsx b/client/src/Providers/ActivePanelContext.tsx index 46b2a189b7..9db0a3242c 100644 --- a/client/src/Providers/ActivePanelContext.tsx +++ b/client/src/Providers/ActivePanelContext.tsx @@ -1,7 +1,7 @@ import { createContext, useCallback, useContext, useMemo, useState, ReactNode } from 'react'; const STORAGE_KEY = 'side:active-panel'; -const DEFAULT_PANEL = 'conversations'; +export const DEFAULT_PANEL = 'conversations'; function getInitialActivePanel(): string { const saved = localStorage.getItem(STORAGE_KEY); diff --git a/client/src/components/Nav/SettingsTabs/General/General.tsx b/client/src/components/Nav/SettingsTabs/General/General.tsx index b86abc543e..90224ac032 100644 --- a/client/src/components/Nav/SettingsTabs/General/General.tsx +++ b/client/src/components/Nav/SettingsTabs/General/General.tsx @@ -29,6 +29,13 @@ const toggleSwitchConfigs = [ hoverCardText: undefined, key: 'keepScreenAwake', }, + { + stateAtom: store.newChatSwitchToHistory, + localizationKey: 'com_nav_new_chat_switch_to_history' as const, + switchId: 'newChatSwitchToHistory', + hoverCardText: undefined, + key: 'newChatSwitchToHistory', + }, ]; export const ThemeSelector = ({ diff --git a/client/src/components/UnifiedSidebar/ExpandedPanel.tsx b/client/src/components/UnifiedSidebar/ExpandedPanel.tsx index f6e049005e..c0d3f165d5 100644 --- a/client/src/components/UnifiedSidebar/ExpandedPanel.tsx +++ b/client/src/components/UnifiedSidebar/ExpandedPanel.tsx @@ -6,18 +6,23 @@ import { QueryKeys } from 'librechat-data-provider'; import { Skeleton, Sidebar, Button, TooltipAnchor } from '@librechat/client'; import type { NavLink } from '~/common'; import { CLOSE_SIDEBAR_ID } from '~/components/Chat/Menus/OpenSidebar'; -import { useActivePanel, resolveActivePanel } from '~/Providers'; +import { useActivePanel, resolveActivePanel, DEFAULT_PANEL } from '~/Providers'; import { useLocalize, useNewConvo } from '~/hooks'; import { clearMessagesCache, cn } from '~/utils'; import store from '~/store'; const AccountSettings = lazy(() => import('~/components/Nav/AccountSettings')); -const NewChatButton = memo(function NewChatButton() { +const NewChatButton = memo(function NewChatButton({ + setActive, +}: { + setActive: (id: string) => void; +}) { const localize = useLocalize(); const queryClient = useQueryClient(); const { newConversation } = useNewConvo(); const conversation = useRecoilValue(store.conversationByIndex(0)); + const switchToHistory = useRecoilValue(store.newChatSwitchToHistory); const handleClick = useCallback( (e: React.MouseEvent) => { @@ -26,9 +31,12 @@ const NewChatButton = memo(function NewChatButton() { clearMessagesCache(queryClient, conversation?.conversationId); queryClient.invalidateQueries([QueryKeys.messages]); newConversation(); + if (switchToHistory) { + setActive(DEFAULT_PANEL); + } } }, - [queryClient, conversation?.conversationId, newConversation], + [queryClient, conversation?.conversationId, newConversation, switchToHistory, setActive], ); return ( @@ -56,12 +64,14 @@ const NavIconButton = memo(function NavIconButton({ expanded, setActive, onExpand, + onCollapse, }: { link: NavLink; isActive: boolean; expanded: boolean; setActive: (id: string) => void; onExpand?: () => void; + onCollapse?: () => void; }) { const localize = useLocalize(); @@ -71,6 +81,10 @@ const NavIconButton = memo(function NavIconButton({ link.onClick(e); return; } + if (isActive && expanded) { + onCollapse?.(); + return; + } if (!isActive) { setActive(link.id); } @@ -78,7 +92,7 @@ const NavIconButton = memo(function NavIconButton({ onExpand?.(); } }, - [link, isActive, setActive, expanded, onExpand], + [link, isActive, setActive, expanded, onExpand, onCollapse], ); return ( @@ -142,7 +156,7 @@ function ExpandedPanel({ } /> - +
{links.map((link) => ( @@ -153,6 +167,7 @@ function ExpandedPanel({ expanded={expanded ?? true} setActive={setActive} onExpand={onExpand} + onCollapse={onCollapse} /> ))}
diff --git a/client/src/components/UnifiedSidebar/__tests__/ExpandedPanel.spec.tsx b/client/src/components/UnifiedSidebar/__tests__/ExpandedPanel.spec.tsx new file mode 100644 index 0000000000..a79ce80c81 --- /dev/null +++ b/client/src/components/UnifiedSidebar/__tests__/ExpandedPanel.spec.tsx @@ -0,0 +1,167 @@ +import React from 'react'; +import { RecoilRoot } from 'recoil'; +import '@testing-library/jest-dom/extend-expect'; +import { MessagesSquare, NotebookPen } from 'lucide-react'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { MutableSnapshot } from 'recoil'; +import { ActivePanelProvider, DEFAULT_PANEL } from '~/Providers/ActivePanelContext'; + +const mockNewConversation = jest.fn(); +const mockClearMessagesCache = jest.fn(); + +jest.mock('~/store', () => { + const { atom } = jest.requireActual('recoil'); + let counter = 0; + const switchAtom = atom({ + key: 'mock-newChatSwitchToHistory', + default: true, + }); + return { + __esModule: true, + default: { + conversationByIndex: () => + atom({ key: `mock-conversationByIndex-${counter++}`, default: null }), + newChatSwitchToHistory: switchAtom, + }, + }; +}); + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, + useNewConvo: () => ({ newConversation: mockNewConversation }), +})); + +jest.mock('~/utils', () => ({ + clearMessagesCache: (...args: unknown[]) => mockClearMessagesCache(...args), + cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), +})); + +jest.mock('~/components/Chat/Menus/OpenSidebar', () => ({ + CLOSE_SIDEBAR_ID: 'close-sidebar', +})); + +jest.mock('~/components/Nav/AccountSettings', () => ({ + __esModule: true, + default: () =>
, +})); + +import ExpandedPanel from '../ExpandedPanel'; +import store from '~/store'; + +const createLinks = () => [ + { + title: 'com_ui_chat_history' as const, + icon: MessagesSquare, + id: DEFAULT_PANEL, + }, + { + title: 'com_ui_prompts' as const, + icon: NotebookPen, + id: 'prompts', + }, +]; + +const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +function renderPanel({ + expanded = true, + onCollapse = jest.fn(), + onExpand = jest.fn(), + initialPanel = DEFAULT_PANEL, + initializeState, +}: { + expanded?: boolean; + onCollapse?: jest.Mock; + onExpand?: jest.Mock; + initialPanel?: string; + initializeState?: (snapshot: MutableSnapshot) => void; +} = {}) { + if (initialPanel !== DEFAULT_PANEL) { + localStorage.setItem('side:active-panel', initialPanel); + } + + const result = render( + + + + + + + , + ); + + return { ...result, onCollapse, onExpand }; +} + +describe('ExpandedPanel', () => { + beforeEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + }); + + describe('NavIconButton collapse toggle', () => { + it('collapses sidebar when clicking the active icon while expanded', () => { + const { onCollapse } = renderPanel({ expanded: true }); + const activeButton = screen.getByRole('button', { name: 'com_ui_chat_history' }); + fireEvent.click(activeButton); + expect(onCollapse).toHaveBeenCalledTimes(1); + }); + + it('switches panel when clicking an inactive icon while expanded', () => { + const { onCollapse } = renderPanel({ expanded: true }); + const inactiveButton = screen.getByRole('button', { name: 'com_ui_prompts' }); + fireEvent.click(inactiveButton); + expect(onCollapse).not.toHaveBeenCalled(); + expect(localStorage.getItem('side:active-panel')).toBe('prompts'); + }); + + it('expands sidebar when clicking any icon while collapsed', () => { + const { onExpand } = renderPanel({ expanded: false }); + const activeButton = screen.getByRole('button', { name: 'com_ui_chat_history' }); + fireEvent.click(activeButton); + expect(onExpand).toHaveBeenCalledTimes(1); + }); + + it('sets active panel and expands when clicking an inactive icon while collapsed', () => { + const { onExpand } = renderPanel({ expanded: false }); + const inactiveButton = screen.getByRole('button', { name: 'com_ui_prompts' }); + fireEvent.click(inactiveButton); + expect(onExpand).toHaveBeenCalledTimes(1); + expect(localStorage.getItem('side:active-panel')).toBe('prompts'); + }); + }); + + describe('NewChatButton panel switch', () => { + it('switches to chat history panel on new chat click when setting is enabled', () => { + renderPanel({ expanded: true, initialPanel: 'prompts' }); + + const newChatLink = screen.getByTestId('new-chat-button'); + fireEvent.click(newChatLink); + + expect(mockNewConversation).toHaveBeenCalledTimes(1); + expect(localStorage.getItem('side:active-panel')).toBe(DEFAULT_PANEL); + }); + + it('does not switch panel on new chat click when setting is disabled', () => { + renderPanel({ + expanded: true, + initialPanel: 'prompts', + initializeState: ({ set }: MutableSnapshot) => { + set(store.newChatSwitchToHistory, false); + }, + }); + + const newChatLink = screen.getByTestId('new-chat-button'); + fireEvent.click(newChatLink); + + expect(mockNewConversation).toHaveBeenCalledTimes(1); + expect(localStorage.getItem('side:active-panel')).toBe('prompts'); + }); + }); +}); diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 4daf569176..2afc391762 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -501,6 +501,7 @@ "com_nav_info_show_thinking": "When enabled, the chat will display the thinking dropdowns open by default, allowing you to view the AI's reasoning in real-time. When disabled, the thinking dropdowns will remain closed by default for a cleaner and more streamlined interface", "com_nav_info_user_name_display": "When enabled, the username of the sender will be shown above each message you send. When disabled, you will only see \"You\" above your messages.", "com_nav_keep_screen_awake": "Keep screen awake during response generation", + "com_nav_new_chat_switch_to_history": "Switch to Chat History on new chat", "com_nav_lang_arabic": "العربية", "com_nav_lang_armenian": "Հայերեն", "com_nav_lang_auto": "Auto detect", diff --git a/client/src/store/settings.ts b/client/src/store/settings.ts index e264bdb127..84bb815019 100644 --- a/client/src/store/settings.ts +++ b/client/src/store/settings.ts @@ -26,6 +26,7 @@ const localStorageAtoms = { true, ), keepScreenAwake: atomWithLocalStorage('keepScreenAwake', true), + newChatSwitchToHistory: atomWithLocalStorage('newChatSwitchToHistory', true), // Chat settings enterToSend: atomWithLocalStorage('enterToSend', true),