🗂️ feat: Sidebar Icon Toggle & New Chat History Switch (#12642)

* 🗂️ 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.
This commit is contained in:
Danny Avila 2026-04-13 09:46:38 -04:00 committed by GitHub
parent 9b9a86d17d
commit 7b48203906
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 197 additions and 6 deletions

View file

@ -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);

View file

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

View file

@ -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<HTMLAnchorElement>) => {
@ -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({
</Button>
}
/>
<NewChatButton />
<NewChatButton setActive={setActive} />
<div className="mx-2 border-b border-border-light" />
<div className="flex flex-col gap-1 overflow-y-auto">
{links.map((link) => (
@ -153,6 +167,7 @@ function ExpandedPanel({
expanded={expanded ?? true}
setActive={setActive}
onExpand={onExpand}
onCollapse={onCollapse}
/>
))}
</div>

View file

@ -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: () => <div data-testid="account-settings" />,
}));
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(
<QueryClientProvider client={createQueryClient()}>
<RecoilRoot initializeState={initializeState}>
<ActivePanelProvider>
<ExpandedPanel
links={createLinks()}
expanded={expanded}
onCollapse={onCollapse}
onExpand={onExpand}
/>
</ActivePanelProvider>
</RecoilRoot>
</QueryClientProvider>,
);
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');
});
});
});

View file

@ -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",

View file

@ -26,6 +26,7 @@ const localStorageAtoms = {
true,
),
keepScreenAwake: atomWithLocalStorage('keepScreenAwake', true),
newChatSwitchToHistory: atomWithLocalStorage('newChatSwitchToHistory', true),
// Chat settings
enterToSend: atomWithLocalStorage('enterToSend', true),