🪹 feat: Shared Empty State for Side Panels (#15123)

* feat: shared empty state for side panels

Bookmarks and Memories each hand-rolled the same empty state: the same bordered
card, the same circular icon surface, the same title and caption sizes, written
out twice. Schedules had none at all, so an account with no schedules got a bare
list with nothing to explain what the panel is for.

One EmptyState primitive in packages/client, taking an icon, an optional title
and description, and an optional action. Bookmarks and Memories move onto it with
no visual change and no copy change. Schedules gets a real empty state, and an
error state with a Retry action, so a panel that failed to load offers a way out
instead of looking empty.

A description with no title takes the title's size rather than the caption's:
where it is the only line, it IS the message.

* fix: drop the create hint for roles without schedule create access

The panel already hides its create button behind hasCreateAccess, but
the empty state still told a USE-only viewer to create a schedule it
offers no way to create. The invitation now renders only when the
capability does.

* fix: suppress the create hint when the quota already blocks creation

A maxPerUser of 0 disables the create button on an empty list, so the
empty state must not say to create one either; the hint now follows the
same effective gate as the button.
This commit is contained in:
Marco Beretta 2026-08-24 00:56:34 +02:00 committed by GitHub
parent fc2b8584c4
commit dd85c6d6d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 196 additions and 48 deletions

View file

@ -1,4 +1,5 @@
import { Bookmark } from 'lucide-react';
import { EmptyState } from '@librechat/client';
import { useLocalize } from '~/hooks';
interface BookmarkEmptyStateProps {
@ -8,23 +9,15 @@ interface BookmarkEmptyStateProps {
export default function BookmarkEmptyState({ isFiltered = false }: BookmarkEmptyStateProps) {
const localize = useLocalize();
if (isFiltered) {
return <EmptyState icon={Bookmark} description={localize('com_ui_no_bookmarks_match')} />;
}
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-border-light bg-transparent p-6 text-center">
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-surface-tertiary">
<Bookmark className="size-5 text-text-secondary" aria-hidden="true" />
</div>
{isFiltered ? (
<p className="text-sm text-text-secondary">{localize('com_ui_no_bookmarks_match')}</p>
) : (
<>
<p className="text-sm font-medium text-text-primary">
{localize('com_ui_no_bookmarks_title')}
</p>
<p className="mt-0.5 text-xs text-text-secondary">
{localize('com_ui_add_first_bookmark')}
</p>
</>
)}
</div>
<EmptyState
icon={Bookmark}
title={localize('com_ui_no_bookmarks_title')}
description={localize('com_ui_add_first_bookmark')}
/>
);
}

View file

@ -1,4 +1,5 @@
import { Brain } from 'lucide-react';
import { EmptyState } from '@librechat/client';
import { useLocalize } from '~/hooks';
interface MemoryEmptyStateProps {
@ -8,21 +9,15 @@ interface MemoryEmptyStateProps {
export default function MemoryEmptyState({ isFiltered = false }: MemoryEmptyStateProps) {
const localize = useLocalize();
if (isFiltered) {
return <EmptyState icon={Brain} description={localize('com_ui_no_memories_match')} />;
}
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-border-light bg-transparent p-6 text-center">
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-surface-tertiary">
<Brain className="size-5 text-text-secondary" aria-hidden="true" />
</div>
{isFiltered ? (
<p className="text-sm text-text-secondary">{localize('com_ui_no_memories_match')}</p>
) : (
<>
<p className="text-sm font-medium text-text-primary">
{localize('com_ui_no_memories_title')}
</p>
<p className="mt-0.5 text-xs text-text-secondary">{localize('com_ui_no_memories')}</p>
</>
)}
</div>
<EmptyState
icon={Brain}
title={localize('com_ui_no_memories_title')}
description={localize('com_ui_no_memories')}
/>
);
}

View file

@ -0,0 +1,41 @@
import { Button, EmptyState } from '@librechat/client';
import { CalendarClock, TriangleAlert } from 'lucide-react';
import { useLocalize } from '~/hooks';
interface ScheduleEmptyStateProps {
canCreate?: boolean;
isError?: boolean;
onRetry?: () => void;
}
export default function ScheduleEmptyState({
canCreate = false,
isError = false,
onRetry,
}: ScheduleEmptyStateProps) {
const localize = useLocalize();
if (isError) {
return (
<EmptyState
icon={TriangleAlert}
title={localize('com_ui_schedules_error')}
action={
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
{localize('com_ui_retry')}
</Button>
}
/>
);
}
return (
<EmptyState
icon={CalendarClock}
title={localize('com_ui_no_schedules_title')}
// Only invite creation where the panel actually offers it: a USE-only role
// has no create button, so telling it to create one is a dead end.
description={canCreate ? localize('com_ui_no_schedules') : undefined}
/>
);
}

View file

@ -5,6 +5,7 @@ import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { PanelContent, PanelFooter } from '~/components/ui';
import { useChatProjectNames } from './useScheduleProjects';
import ScheduleCardSkeleton from './ScheduleCardSkeleton';
import ScheduleEmptyState from './ScheduleEmptyState';
import { useSchedulesQuery } from '~/data-provider';
import { useLocalize, useHasAccess } from '~/hooks';
import ScheduleDialog from './ScheduleDialog';
@ -32,22 +33,9 @@ export default function SchedulePanel() {
let panelContent: ReactNode;
if (isError) {
panelContent = (
<div className="flex min-h-40 flex-col items-center justify-center gap-2 p-4">
<p className="text-center text-sm text-text-secondary">
{localize('com_ui_schedules_error')}
</p>
<Button type="button" variant="outline" size="sm" onClick={() => refetch()}>
{localize('com_ui_retry')}
</Button>
</div>
);
panelContent = <ScheduleEmptyState isError onRetry={() => refetch()} />;
} else if (schedules.length === 0) {
panelContent = (
<div className="rounded-lg border border-dashed border-border-light p-4 text-center">
<p className="text-sm text-text-secondary">{localize('com_ui_schedules_empty')}</p>
</div>
);
panelContent = <ScheduleEmptyState canCreate={hasCreateAccess && !atLimit} />;
} else {
panelContent = (
<div className="space-y-2" role="list" aria-label={localize('com_ui_schedules')}>

View file

@ -0,0 +1,39 @@
import userEvent from '@testing-library/user-event';
import { render, screen } from '@testing-library/react';
import ScheduleEmptyState from '../ScheduleEmptyState';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en-US' } }),
}));
describe('ScheduleEmptyState', () => {
it('shows the title and the create hint for a role that can create', () => {
render(<ScheduleEmptyState canCreate />);
expect(screen.getByText('com_ui_no_schedules_title')).toBeInTheDocument();
expect(screen.getByText('com_ui_no_schedules')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('drops the create hint for a role the panel offers no create button to', () => {
render(<ScheduleEmptyState />);
expect(screen.getByText('com_ui_no_schedules_title')).toBeInTheDocument();
expect(screen.queryByText('com_ui_no_schedules')).not.toBeInTheDocument();
});
it('swaps to the error message and retries on request', async () => {
const onRetry = jest.fn();
render(<ScheduleEmptyState isError onRetry={onRetry} />);
expect(screen.getByText('com_ui_schedules_error')).toBeInTheDocument();
expect(screen.queryByText('com_ui_no_schedules_title')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'com_ui_retry' }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
});

View file

@ -1640,6 +1640,8 @@
"com_ui_no_prompts_title": "No prompts yet",
"com_ui_no_read_access": "You don't have permission to view memories",
"com_ui_no_results_found": "No results found",
"com_ui_no_schedules": "Create one to have an agent run a prompt on a schedule",
"com_ui_no_schedules_title": "No scheduled chats yet",
"com_ui_no_search_results": "No results match your search",
"com_ui_no_selection": "No selection",
"com_ui_no_skills_found": "No skills found",
@ -1925,7 +1927,6 @@
"com_ui_schedule_weekly": "Weekly",
"com_ui_schedules": "Scheduled chats",
"com_ui_schedules_error": "Couldn't load your scheduled chats",
"com_ui_schedules_empty": "No scheduled chats yet",
"com_ui_schedules_used": "{{used}} of {{max}} schedules used",
"com_ui_schema": "Schema",
"com_ui_scope": "Scope",

View file

@ -0,0 +1,39 @@
import { Brain } from 'lucide-react';
import { render, screen } from '@testing-library/react';
import EmptyState from './EmptyState';
describe('EmptyState', () => {
it('renders a title, a description and a decorative icon', () => {
const { container } = render(
<EmptyState icon={Brain} title="No memories yet" description="Add one to get started" />,
);
expect(screen.getByText('No memories yet')).toBeInTheDocument();
expect(screen.getByText('Add one to get started')).toBeInTheDocument();
// The icon carries the meaning already in the text, so it must not be announced.
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
});
it("gives a description the title's size when it stands alone", () => {
// The "nothing matched your filter" shape has no title, so its one line must not
// render as a caption underneath nothing.
render(<EmptyState icon={Brain} description="No results match your filter" />);
expect(screen.getByText('No results match your filter')).toHaveClass('text-sm');
expect(screen.getByText('No results match your filter')).not.toHaveClass('text-xs');
});
it('renders an action when one is given, and nothing when it is not', () => {
const { rerender } = render(<EmptyState icon={Brain} title="Could not load" />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
rerender(
<EmptyState
icon={Brain}
title="Could not load"
action={<button type="button">Retry</button>}
/>,
);
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
});
});

View file

@ -0,0 +1,50 @@
import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { cn } from '~/utils';
export interface EmptyStateProps {
/** Decorative, rendered inside the circular surface at a fixed size. */
icon: LucideIcon;
/** Omitted for the "nothing matched your filter" shape, which is a line on its own. */
title?: string;
description?: string;
/** A single call to action, e.g. a retry button. */
action?: ReactNode;
className?: string;
}
/**
* The panel empty state: a bordered card with a circular icon, a title and a line of
* explanation. Owned here because bookmarks, memories and schedules each render the
* same card, and three copies of one appearance means a theme or spacing change has
* to be made three times and will eventually be made twice.
*/
export default function EmptyState({
icon: Icon,
title,
description,
action,
className,
}: EmptyStateProps): JSX.Element {
return (
<div
className={cn(
'flex flex-col items-center justify-center rounded-lg border border-border-light bg-transparent p-6 text-center',
className,
)}
>
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-surface-tertiary">
<Icon className="size-5 text-text-secondary" aria-hidden={true} />
</div>
{title != null && <p className="text-sm font-medium text-text-primary">{title}</p>}
{description != null && (
// Without a title the description IS the message, so it carries the title's
// size rather than reading as a caption under nothing.
<p className={cn(title == null ? 'text-sm' : 'mt-0.5 text-xs', 'text-text-secondary')}>
{description}
</p>
)}
{action != null && <div className="mt-3">{action}</div>}
</div>
);
}

View file

@ -58,6 +58,8 @@ export { default as CheckboxButton } from './CheckboxButton';
export { default as DialogTemplate } from './DialogTemplate';
export { default as SelectDropDown } from './SelectDropDown';
export { default as ControlCombobox } from './ControlCombobox';
export { default as EmptyState } from './EmptyState';
export type { EmptyStateProps } from './EmptyState';
export { default as TimePicker, MinutePicker, TimeColumn } from './TimePicker';
export type {
TimePickerProps,