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,