mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🗂️ 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:
parent
9b9a86d17d
commit
7b48203906
6 changed files with 197 additions and 6 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const localStorageAtoms = {
|
|||
true,
|
||||
),
|
||||
keepScreenAwake: atomWithLocalStorage('keepScreenAwake', true),
|
||||
newChatSwitchToHistory: atomWithLocalStorage('newChatSwitchToHistory', true),
|
||||
|
||||
// Chat settings
|
||||
enterToSend: atomWithLocalStorage('enterToSend', true),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue