diff --git a/apps/next/src/components/layout/header/controls/index.tsx b/apps/next/src/components/layout/header/controls/index.tsx
index 015fe22..de9c7d2 100644
--- a/apps/next/src/components/layout/header/controls/index.tsx
+++ b/apps/next/src/components/layout/header/controls/index.tsx
@@ -4,6 +4,7 @@ import type { ThemeToggleProps } from '@spoon/ui';
import { ThemeToggle } from '@spoon/ui';
import { AvatarDropdown } from './AvatarDropdown';
+import { NotificationBell } from './notification-bell';
export const Controls = (themeToggleProps?: ThemeToggleProps) => {
return (
@@ -16,6 +17,7 @@ export const Controls = (themeToggleProps?: ThemeToggleProps) => {
...themeToggleProps?.buttonProps,
}}
/>
+
);
diff --git a/apps/next/src/components/layout/header/controls/notification-bell.tsx b/apps/next/src/components/layout/header/controls/notification-bell.tsx
new file mode 100644
index 0000000..3627441
--- /dev/null
+++ b/apps/next/src/components/layout/header/controls/notification-bell.tsx
@@ -0,0 +1,149 @@
+'use client';
+
+import Link from 'next/link';
+import { Bell } from 'lucide-react';
+import { toast } from 'sonner';
+import { useConvexAuth, useMutation, useQuery } from 'convex/react';
+
+import { api } from '@spoon/backend/convex/_generated/api.js';
+import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
+import {
+ Button,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@spoon/ui';
+
+const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
+ ['year', 365 * 24 * 60 * 60 * 1000],
+ ['month', 30 * 24 * 60 * 60 * 1000],
+ ['day', 24 * 60 * 60 * 1000],
+ ['hour', 60 * 60 * 1000],
+ ['minute', 60 * 1000],
+];
+
+const relativeFormatter = new Intl.RelativeTimeFormat(undefined, {
+ numeric: 'auto',
+});
+
+const formatRelativeTime = (timestamp: number): string => {
+ const delta = timestamp - Date.now();
+ const magnitude = Math.abs(delta);
+ for (const [unit, ms] of RELATIVE_UNITS) {
+ if (magnitude >= ms) {
+ return relativeFormatter.format(Math.round(delta / ms), unit);
+ }
+ }
+ return 'just now';
+};
+
+export const NotificationBell = () => {
+ const { isAuthenticated } = useConvexAuth();
+ const unreadCount = useQuery(
+ api.notifications.unreadCount,
+ isAuthenticated ? {} : 'skip',
+ );
+ const notifications = useQuery(
+ api.notifications.listMine,
+ isAuthenticated ? { limit: 10 } : 'skip',
+ );
+ const markRead = useMutation(api.notifications.markRead);
+ const markAllRead = useMutation(api.notifications.markAllRead);
+
+ if (!isAuthenticated) {
+ return null;
+ }
+
+ const count = unreadCount ?? 0;
+ const badgeLabel = count > 9 ? '9+' : String(count);
+ const items = notifications ?? [];
+
+ // Fire-and-forget: never block navigation on the read write, but surface a
+ // toast if it fails so we don't silently assume success.
+ const handleMarkRead = async (notificationId: Id<'notifications'>) => {
+ try {
+ await markRead({ notificationId });
+ } catch {
+ toast.error('Could not mark the notification as read.');
+ }
+ };
+
+ const handleMarkAllRead = async () => {
+ try {
+ await markAllRead({});
+ } catch {
+ toast.error('Could not mark notifications as read.');
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ Notifications
+ {count > 0 && (
+
+ )}
+
+
+ {items.length === 0 ? (
+
+ No notifications yet.
+
+ ) : (
+ items.map((notification) => (
+
+ void handleMarkRead(notification._id)}
+ className='flex w-full cursor-pointer flex-col items-start gap-0.5'
+ >
+
+
+ {notification.title}
+
+ {notification.readAt === undefined && (
+
+ )}
+
+
+ {notification.body}
+
+
+ {formatRelativeTime(notification.createdAt)}
+
+
+
+ ))
+ )}
+
+
+ );
+};
diff --git a/apps/next/tests/component/notification-bell.test.tsx b/apps/next/tests/component/notification-bell.test.tsx
new file mode 100644
index 0000000..37cb96b
--- /dev/null
+++ b/apps/next/tests/component/notification-bell.test.tsx
@@ -0,0 +1,135 @@
+import type { ComponentProps, ReactNode } from 'react';
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { api } from '@spoon/backend/convex/_generated/api.js';
+
+import { NotificationBell } from '../../src/components/layout/header/controls/notification-bell';
+
+// Stable sentinels: the real Convex `api` proxy hands back a fresh reference on
+// every access, so `===` comparisons in the mocks below would never match.
+vi.mock('@spoon/backend/convex/_generated/api.js', () => ({
+ api: {
+ notifications: {
+ unreadCount: 'notifications:unreadCount',
+ listMine: 'notifications:listMine',
+ markRead: 'notifications:markRead',
+ markAllRead: 'notifications:markAllRead',
+ },
+ },
+}));
+
+const { mockMarkAllRead, mockMarkRead, mockUseConvexAuth, mockUseQuery } =
+ vi.hoisted(() => ({
+ mockMarkAllRead: vi.fn(),
+ mockMarkRead: vi.fn(),
+ mockUseConvexAuth: vi.fn(),
+ mockUseQuery: vi.fn(),
+ }));
+
+vi.mock('convex/react', () => ({
+ useConvexAuth: mockUseConvexAuth,
+ useMutation: (reference: unknown) =>
+ reference === api.notifications.markAllRead ? mockMarkAllRead : mockMarkRead,
+ useQuery: mockUseQuery,
+}));
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
+}));
+
+vi.mock('sonner', () => ({
+ toast: { error: vi.fn(), success: vi.fn() },
+}));
+
+// Render the dropdown primitives as pass-through wrappers so the menu content
+// is deterministically present under jsdom (Radix pointer behavior is flaky
+// here). We are exercising NotificationBell's logic, not Radix.
+vi.mock('@spoon/ui', () => ({
+ Button: (props: ComponentProps<'button'>) => ,
+ DropdownMenu: ({ children }: { children?: ReactNode }) =>
{children}
,
+ DropdownMenuTrigger: ({ children }: { children?: ReactNode }) => <>{children}>,
+ DropdownMenuContent: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+ DropdownMenuItem: ({ children }: { children?: ReactNode }) => <>{children}>,
+ DropdownMenuLabel: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+ DropdownMenuSeparator: () =>
,
+}));
+
+const notifications = [
+ {
+ _id: 'notif-1',
+ _creationTime: 2,
+ userId: 'user-1',
+ kind: 'agent_turn_finished',
+ title: 'Agent finished its turn',
+ body: 'useSend is ready for review.',
+ link: '/threads/thread-1',
+ createdAt: Date.now() - 60_000,
+ },
+ {
+ _id: 'notif-2',
+ _creationTime: 1,
+ userId: 'user-1',
+ kind: 'sync_failed',
+ title: 'Sync failed',
+ body: 'Could not sync useSend with upstream.',
+ link: '/spoons/spoon-1',
+ createdAt: Date.now() - 3_600_000,
+ },
+];
+
+const seedQueries = (unreadCount: number) => {
+ mockUseConvexAuth.mockReturnValue({ isAuthenticated: true, isLoading: false });
+ mockUseQuery.mockImplementation((reference: unknown) => {
+ if (reference === api.notifications.unreadCount) return unreadCount;
+ if (reference === api.notifications.listMine) return notifications;
+ return undefined;
+ });
+};
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+describe('NotificationBell', () => {
+ it('renders the unread badge with the count', () => {
+ seedQueries(3);
+ render();
+ expect(screen.getByText('3')).toBeInTheDocument();
+ });
+
+ it('renders the notification titles when opened', () => {
+ seedQueries(3);
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
+ expect(screen.getByText('Agent finished its turn')).toBeInTheDocument();
+ expect(screen.getByText('Sync failed')).toBeInTheDocument();
+ });
+
+ it('marks a notification read when it is clicked', () => {
+ seedQueries(3);
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
+ fireEvent.click(screen.getByText('Agent finished its turn'));
+ expect(mockMarkRead).toHaveBeenCalledWith({ notificationId: 'notif-1' });
+ });
+
+ it('marks all read when the action is clicked', () => {
+ seedQueries(3);
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
+ fireEvent.click(screen.getByRole('button', { name: /mark all read/i }));
+ expect(mockMarkAllRead).toHaveBeenCalled();
+ });
+
+ it('hides the badge when there are no unread notifications', () => {
+ seedQueries(0);
+ render();
+ expect(screen.queryByText('0')).not.toBeInTheDocument();
+ });
+});