Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 5 additions & 17 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"format:check": "prettier --check 'src/**/*.{ts,tsx,css,json}'"
},
"dependencies": {
"@clickhouse/click-ui": "0.2.0-rc.4",
"@clickhouse/click-ui": "0.9.1",
"@librechat/data-schemas": "^0.0.56",
"@radix-ui/react-dialog": "1.1.15",
"@tailwindcss/vite": "^4.3.1",
Expand Down
43 changes: 35 additions & 8 deletions src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,23 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {
)}
>
<div className="flex h-14 shrink-0 items-center px-2">
<div className="flex items-center gap-2.5 overflow-hidden px-1.5">
<img src={libreChatLogo} alt={localize('com_a11y_logo_alt')} className="h-6 w-6 shrink-0" />
<span className="truncate text-sm font-semibold text-(--cui-color-text-default)">
<div
className={cn(
'flex items-center overflow-hidden',
collapsed ? 'w-10 justify-center gap-0 px-0' : 'gap-2.5 px-1.5',
)}
>
<img
src={libreChatLogo}
alt={localize('com_a11y_logo_alt')}
className="h-6 w-6 shrink-0"
/>
<span
className={cn(
'truncate text-sm font-semibold text-(--cui-color-text-default)',
collapsed && 'w-0 flex-none',
)}
>
{localize('com_auth_title')}
</span>
</div>
Expand All @@ -105,7 +119,8 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {
aria-label={collapsed ? localize(item.labelKey) : undefined}
title={collapsed ? localize(item.labelKey) : undefined}
className={cn(
'flex h-8 items-center gap-2.5 overflow-hidden rounded-md px-2.5 text-sm whitespace-nowrap no-underline transition-colors duration-100',
'flex h-8 items-center overflow-hidden rounded-md px-2.5 text-sm whitespace-nowrap no-underline transition-colors duration-100',
collapsed ? 'w-10 justify-center gap-0' : 'gap-2.5',
isActive(item.path)
? 'bg-(--cui-color-background-active) font-medium text-(--cui-color-text-default)'
: 'font-normal text-(--cui-color-text-muted) hover:bg-(--cui-color-background-hover) hover:text-(--cui-color-text-default)',
Expand All @@ -114,15 +129,22 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {
<span aria-hidden="true" className="shrink-0">
<Icon name={item.icon} size="sm" />
</span>
<span className="truncate text-sm">{localize(item.labelKey)}</span>
<span className={cn('truncate text-sm', collapsed && 'w-0 flex-none')}>
{localize(item.labelKey)}
</span>
</Link>
))}
</div>
</nav>

{initials && (
<div className="flex shrink-0 items-center border-t border-(--cui-color-stroke-default) px-2 py-3">
<div className="flex items-center gap-2.5 overflow-hidden px-0.5">
<div
className={cn(
'flex items-center',
collapsed ? 'w-10 justify-center gap-0 px-0' : 'gap-2.5 px-0.5',
)}
>
<Dropdown>
<Dropdown.Trigger>
<button
Expand Down Expand Up @@ -166,7 +188,9 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {
</Dropdown.Content>
</Dropdown>
{user && (
<div className="min-w-0 flex-1">
<div
className={cn('min-w-0', collapsed ? 'w-0 flex-none overflow-hidden' : 'flex-1')}
>
<span className="block truncate text-sm leading-tight font-medium text-(--cui-color-text-default)">
{user.name || ''}
</span>
Expand All @@ -186,7 +210,10 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {
onClick={onToggle}
aria-label={localize(collapsed ? 'com_nav_expand_sidebar' : 'com_nav_collapse_sidebar')}
title={localize(collapsed ? 'com_nav_expand_sidebar' : 'com_nav_collapse_sidebar')}
className="flex w-full shrink-0 cursor-pointer items-center justify-center border-t border-(--cui-color-stroke-default) bg-transparent py-3 text-(--cui-color-text-muted) transition-colors hover:bg-(--cui-color-background-hover) hover:text-(--cui-color-text-default)"
className={cn(
'sidebar-toggle',
'flex w-full shrink-0 cursor-pointer items-center justify-center border-t border-(--cui-color-stroke-default) bg-transparent py-3 text-(--cui-color-text-muted) transition-colors hover:bg-(--cui-color-background-hover) hover:text-(--cui-color-text-default)',
)}
>
<Icon name={collapsed ? 'slide-in' : 'slide-out'} size="sm" />
</button>
Expand Down
84 changes: 84 additions & 0 deletions src/components/__tests__/Sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { renderWithLayoutProviders } from '@/test/layout-test-utils';
import { Sidebar } from '../Sidebar';

vi.mock('@/server', async () => {
const { SystemCapabilities } = await import('@/constants');
return {
adminLogoutFn: vi.fn().mockResolvedValue({ error: false, redirect: '' }),
getEffectiveCapabilitiesFn: vi.fn().mockResolvedValue({
capabilities: [
SystemCapabilities.ACCESS_ADMIN,
SystemCapabilities.READ_CONFIGS,
SystemCapabilities.READ_ROLES,
],
}),
};
});

const user = { name: 'Ada Lovelace', email: 'ada@example.com' };

const renderSidebar = (collapsed: boolean, onToggle: () => void = () => {}) =>
renderWithLayoutProviders(<Sidebar user={user} collapsed={collapsed} onToggle={onToggle} />, {
user,
});

describe('Sidebar', () => {
it('centers the logo, nav icons, and avatar in a fixed-width rail when collapsed', async () => {
renderSidebar(true);
const dashboardLink = await screen.findByRole('link', { name: 'Dashboard' });
expect(dashboardLink).toHaveClass('w-10', 'justify-center', 'gap-0');
expect(dashboardLink).not.toHaveClass('gap-2.5');
const logoRow = screen.getByAltText('LibreChat logo').parentElement;
expect(logoRow).toHaveClass('w-10', 'justify-center', 'gap-0');
expect(logoRow).not.toHaveClass('w-full');
const menuButton = screen.getByRole('button', { name: /User menu/ });
const avatarRow = menuButton.closest('.border-t')?.firstElementChild;
expect(avatarRow).toHaveClass('w-10', 'justify-center', 'gap-0');
});

it('strips aria-expanded from the real dropdown trigger wrapper', async () => {
renderSidebar(true);
const menuButton = await screen.findByRole('button', { name: /User menu/ });
await waitFor(() => expect(menuButton.parentElement).not.toHaveAttribute('aria-expanded'));
});

it('renders capability-gated nav items once effective capabilities resolve', async () => {
renderSidebar(true);
expect(await screen.findByRole('link', { name: 'Configuration' })).toBeInTheDocument();
expect(await screen.findByRole('link', { name: 'Access' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Grants' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Help' })).toBeInTheDocument();
});

it('restores gaps and left alignment when expanded', async () => {
renderSidebar(false);
const dashboardLink = await screen.findByRole('link', { name: 'Dashboard' });
expect(dashboardLink).toHaveClass('gap-2.5');
expect(dashboardLink).not.toHaveClass('justify-center');
expect(dashboardLink).not.toHaveClass('w-10');
const logoRow = screen.getByAltText('LibreChat logo').parentElement;
expect(logoRow).toHaveClass('gap-2.5', 'px-1.5');
expect(logoRow).not.toHaveClass('justify-center');
expect(screen.getByText('ada@example.com')).toBeInTheDocument();
});

it('opens the real user menu with settings and sign out actions', async () => {
renderSidebar(true);
const menuButton = await screen.findByRole('button', { name: /User menu/ });
fireEvent.pointerDown(menuButton, { button: 0, ctrlKey: false });
fireEvent.click(menuButton);
expect(await screen.findByText('Sign out')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
});

it('applies the inset focus-ring class to the toggle and calls onToggle', async () => {
const onToggle = vi.fn();
renderSidebar(true, onToggle);
const toggle = await screen.findByRole('button', { name: 'Expand sidebar' });
expect(toggle).toHaveClass('sidebar-toggle');
fireEvent.click(toggle);
expect(onToggle).toHaveBeenCalledTimes(1);
});
});
7 changes: 7 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,13 @@ textarea:focus-visible {
border-radius: var(--cui-radii-sm) !important;
}

/* Sidebar collapse toggle is flush with the panel edges and viewport bottom,
so ancestors clip the default outside ring; draw it inset instead. */
.sidebar-toggle:focus-visible {
outline-offset: -2px !important;
border-radius: var(--cui-radii-sm);
}

.scope-selector-dialog {
width: min(640px, calc(100vw - 2rem));
}
Expand Down
Loading