From dd85c6d6d07b58edb0f57f2a9964acf9ff0f50e1 Mon Sep 17 00:00:00 2001 From: Marco Beretta Date: Mon, 24 Aug 2026 00:56:34 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=B9=20feat:=20Shared=20Empty=20State?= =?UTF-8?q?=20for=20Side=20Panels=20(#15123)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../Bookmarks/BookmarkEmptyState.tsx | 27 ++++------ .../SidePanel/Memories/MemoryEmptyState.tsx | 25 ++++------ .../Schedules/ScheduleEmptyState.tsx | 41 +++++++++++++++ .../SidePanel/Schedules/SchedulePanel.tsx | 18 ++----- .../__tests__/ScheduleEmptyState.spec.tsx | 39 +++++++++++++++ client/src/locales/en/translation.json | 3 +- .../client/src/components/EmptyState.spec.tsx | 39 +++++++++++++++ packages/client/src/components/EmptyState.tsx | 50 +++++++++++++++++++ packages/client/src/components/index.ts | 2 + 9 files changed, 196 insertions(+), 48 deletions(-) create mode 100644 client/src/components/SidePanel/Schedules/ScheduleEmptyState.tsx create mode 100644 client/src/components/SidePanel/Schedules/__tests__/ScheduleEmptyState.spec.tsx create mode 100644 packages/client/src/components/EmptyState.spec.tsx create mode 100644 packages/client/src/components/EmptyState.tsx diff --git a/client/src/components/SidePanel/Bookmarks/BookmarkEmptyState.tsx b/client/src/components/SidePanel/Bookmarks/BookmarkEmptyState.tsx index 7613f757ce..421dbd430b 100644 --- a/client/src/components/SidePanel/Bookmarks/BookmarkEmptyState.tsx +++ b/client/src/components/SidePanel/Bookmarks/BookmarkEmptyState.tsx @@ -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 ; + } + return ( -
-
-
- {isFiltered ? ( -

{localize('com_ui_no_bookmarks_match')}

- ) : ( - <> -

- {localize('com_ui_no_bookmarks_title')} -

-

- {localize('com_ui_add_first_bookmark')} -

- - )} -
+ ); } diff --git a/client/src/components/SidePanel/Memories/MemoryEmptyState.tsx b/client/src/components/SidePanel/Memories/MemoryEmptyState.tsx index 091187ec2a..0579adebb7 100644 --- a/client/src/components/SidePanel/Memories/MemoryEmptyState.tsx +++ b/client/src/components/SidePanel/Memories/MemoryEmptyState.tsx @@ -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 ; + } + return ( -
-
-
- {isFiltered ? ( -

{localize('com_ui_no_memories_match')}

- ) : ( - <> -

- {localize('com_ui_no_memories_title')} -

-

{localize('com_ui_no_memories')}

- - )} -
+ ); } diff --git a/client/src/components/SidePanel/Schedules/ScheduleEmptyState.tsx b/client/src/components/SidePanel/Schedules/ScheduleEmptyState.tsx new file mode 100644 index 0000000000..6ca880be4b --- /dev/null +++ b/client/src/components/SidePanel/Schedules/ScheduleEmptyState.tsx @@ -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 ( + + {localize('com_ui_retry')} + + } + /> + ); + } + + return ( + + ); +} diff --git a/client/src/components/SidePanel/Schedules/SchedulePanel.tsx b/client/src/components/SidePanel/Schedules/SchedulePanel.tsx index 333b770d73..7878c18469 100644 --- a/client/src/components/SidePanel/Schedules/SchedulePanel.tsx +++ b/client/src/components/SidePanel/Schedules/SchedulePanel.tsx @@ -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 = ( -
-

- {localize('com_ui_schedules_error')} -

- -
- ); + panelContent = refetch()} />; } else if (schedules.length === 0) { - panelContent = ( -
-

{localize('com_ui_schedules_empty')}

-
- ); + panelContent = ; } else { panelContent = (
diff --git a/client/src/components/SidePanel/Schedules/__tests__/ScheduleEmptyState.spec.tsx b/client/src/components/SidePanel/Schedules/__tests__/ScheduleEmptyState.spec.tsx new file mode 100644 index 0000000000..5df8158711 --- /dev/null +++ b/client/src/components/SidePanel/Schedules/__tests__/ScheduleEmptyState.spec.tsx @@ -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(); + + 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(); + + 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(); + + 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); + }); +}); diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 03c6c50c97..ba75173c96 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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", diff --git a/packages/client/src/components/EmptyState.spec.tsx b/packages/client/src/components/EmptyState.spec.tsx new file mode 100644 index 0000000000..da5af28b21 --- /dev/null +++ b/packages/client/src/components/EmptyState.spec.tsx @@ -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( + , + ); + + 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(); + + 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(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + + rerender( + Retry} + />, + ); + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + }); +}); diff --git a/packages/client/src/components/EmptyState.tsx b/packages/client/src/components/EmptyState.tsx new file mode 100644 index 0000000000..09ff25e1b8 --- /dev/null +++ b/packages/client/src/components/EmptyState.tsx @@ -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 ( +
+
+ +
+ {title != null &&

{title}

} + {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. +

+ {description} +

+ )} + {action != null &&
{action}
} +
+ ); +} diff --git a/packages/client/src/components/index.ts b/packages/client/src/components/index.ts index a511164141..89cd1cb406 100644 --- a/packages/client/src/components/index.ts +++ b/packages/client/src/components/index.ts @@ -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,