diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx
index 8cff9f78..7fd24b6c 100644
--- a/frontend/src/components/EditorLayout.tsx
+++ b/frontend/src/components/EditorLayout.tsx
@@ -20,13 +20,13 @@ import { springs } from '@/lib/motion';
import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highlight';
import { CursorProvider, Cursor, CursorContainer, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
-import { Badge } from './ui/badge';
-import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy } from 'lucide-react';
+import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { LabelPill, LabelDot } from './LabelPill';
import { type Label as StackLabel } from './label-types';
import { LabelAssignPopover } from './LabelAssignPopover';
import { UserProfileDropdown } from './UserProfileDropdown';
+import { NotificationPanel } from './NotificationPanel';
import { apiFetch, fetchForNode } from '@/lib/api';
import { isValidVersion } from '@/lib/version';
import { toast } from '@/components/ui/toast-store';
@@ -37,7 +37,6 @@ import { Checkbox } from './ui/checkbox';
import { GitSourceFields, type ApplyMode } from './stack/GitSourceFields';
import { Skeleton } from './ui/skeleton';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
-import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger } from './ui/context-menu';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -2530,75 +2529,15 @@ export default function EditorLayout() {
{/* RIGHT ZONE: Utilities */}
- {/* Notifications Popover */}
-
-
-
-
- {notifications.filter(n => !n.is_read).length > 0 && (
-
-
-
-
- )}
-
-
-
-
-
Notifications
-
- {notifications.filter(n => !n.is_read).length > 0 && (
-
- Mark all as read
-
- )}
- {notifications.length > 0 && (
-
-
- Clear all
-
- )}
-
-
-
- {notifications.length === 0 ? (
- No notifications
- ) : (
-
- {notifications.map((notif) => (
-
-
-
- {notif.level}
-
- {nodesRef.current.find(n => n.id === notif.nodeId)?.type === 'remote' && (
-
- {notif.nodeName}
-
- )}
-
- {new Date(notif.timestamp).toLocaleString()}
-
-
-
{notif.message}
-
{
- e.stopPropagation();
- deleteNotification(notif);
- }}
- >
-
-
-
- ))}
-
- )}
-
-
-
+ {/* Notifications */}
+
+
{/* User Profile Dropdown */}
= {
+ info: { icon: Info, iconClass: 'text-info', railClass: 'bg-info' },
+ warning: { icon: AlertTriangle, iconClass: 'text-warning', railClass: 'bg-warning' },
+ error: { icon: AlertOctagon, iconClass: 'text-destructive', railClass: 'bg-destructive' },
+};
+
+const DAY_MS = 86_400_000;
+const HOUR_MS = 3_600_000;
+const MINUTE_MS = 60_000;
+
+type GroupLabel = 'Today' | 'Yesterday' | 'This week' | 'Earlier';
+const GROUP_ORDER: GroupLabel[] = ['Today', 'Yesterday', 'This week', 'Earlier'];
+
+function startOfDay(d: Date): number {
+ const c = new Date(d);
+ c.setHours(0, 0, 0, 0);
+ return c.getTime();
+}
+
+function groupByDay(items: NotificationItem[]): { label: GroupLabel; items: NotificationItem[] }[] {
+ const today = startOfDay(new Date());
+ const yesterday = today - DAY_MS;
+ const weekStart = today - 6 * DAY_MS;
+
+ const buckets: Record = {
+ Today: [],
+ Yesterday: [],
+ 'This week': [],
+ Earlier: [],
+ };
+
+ for (const item of items) {
+ const ts = item.timestamp;
+ if (ts >= today) buckets.Today.push(item);
+ else if (ts >= yesterday) buckets.Yesterday.push(item);
+ else if (ts >= weekStart) buckets['This week'].push(item);
+ else buckets.Earlier.push(item);
+ }
+
+ return GROUP_ORDER.map((label) => ({ label, items: buckets[label] })).filter(
+ (g) => g.items.length > 0,
+ );
+}
+
+function formatRelative(ms: number): string {
+ const diff = Date.now() - ms;
+ if (diff < MINUTE_MS) return 'just now';
+ if (diff < HOUR_MS) return `${Math.round(diff / MINUTE_MS)}m ago`;
+ if (diff < DAY_MS) return `${Math.round(diff / HOUR_MS)}h ago`;
+ if (diff < 2 * DAY_MS) return 'yesterday';
+ return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
+}
+
+function applyFilter(items: NotificationItem[], filter: NotifFilter): NotificationItem[] {
+ if (filter === 'unread') return items.filter((n) => !n.is_read);
+ if (filter === 'alerts') return items.filter((n) => n.level === 'warning' || n.level === 'error');
+ return items;
+}
+
+interface NotificationPanelProps {
+ notifications: NotificationItem[];
+ nodes: Node[];
+ onMarkAllRead: () => void;
+ onClearAll: () => void;
+ onDelete: (notif: NotificationItem) => void;
+}
+
+export function NotificationPanel({
+ notifications,
+ nodes,
+ onMarkAllRead,
+ onClearAll,
+ onDelete,
+}: NotificationPanelProps) {
+ const [filter, setFilter] = useState('all');
+
+ const unreadCount = useMemo(
+ () => notifications.filter((n) => !n.is_read).length,
+ [notifications],
+ );
+
+ const remoteNodeIds = useMemo(() => {
+ const ids = new Set();
+ for (const n of nodes) if (n.type === 'remote') ids.add(n.id);
+ return ids;
+ }, [nodes]);
+
+ const filtered = useMemo(() => applyFilter(notifications, filter), [notifications, filter]);
+ const groups = useMemo(() => groupByDay(filtered), [filtered]);
+
+ const filterOptions = useMemo(
+ () => [
+ { value: 'all' as const, label: 'All' },
+ {
+ value: 'unread' as const,
+ label: 'Unread',
+ badge: unreadCount > 0 ? unreadCount : undefined,
+ },
+ { value: 'alerts' as const, label: 'Alerts' },
+ ],
+ [unreadCount],
+ );
+
+ const bellBadge =
+ unreadCount > 0 ? (
+
+ {unreadCount > 99 ? '99+' : unreadCount}
+
+ ) : null;
+
+ return (
+
+
+
+
+ {bellBadge}
+
+
+
+ {/* Masthead */}
+
+
+
+
+
+
+ Notifications
+
+ {unreadCount > 0 ? (
+
+ {unreadCount} unread
+
+ ) : null}
+
+
+ {unreadCount > 0 ? (
+
+ Mark read
+
+ ) : null}
+ {notifications.length > 0 ? (
+
+
+
+ ) : null}
+
+
+
+
+ {/* Filter segment */}
+
+
+
+
+ {/* Stream */}
+ {groups.length === 0 ? (
+ 0} />
+ ) : (
+
+ {groups.map((group) => (
+
+
+ {group.label}
+
+ {group.items.map((notif) => (
+
+ ))}
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+interface NotificationRowProps {
+ notif: NotificationItem;
+ showNodeName: boolean;
+ onDelete: (notif: NotificationItem) => void;
+}
+
+function NotificationRow({ notif, showNodeName, onDelete }: NotificationRowProps) {
+ const config = LEVEL_CONFIG[notif.level];
+ const Icon = config.icon;
+ const isUnread = !notif.is_read;
+
+ return (
+
+
+
+
+
+ {notif.message}
+
+
+ {showNodeName && notif.nodeName ? (
+ <>
+
+ {notif.nodeName}
+
+ ·
+ >
+ ) : null}
+ {formatRelative(notif.timestamp)}
+
+
+
{
+ e.stopPropagation();
+ onDelete(notif);
+ }}
+ title="Dismiss"
+ >
+
+
+
+ );
+}
+
+interface EmptyStateProps {
+ filter: NotifFilter;
+ hasAny: boolean;
+}
+
+function EmptyState({ filter, hasAny }: EmptyStateProps) {
+ let title = "You're all caught up";
+ let subtitle = 'New notifications appear here in real time.';
+
+ if (hasAny && filter === 'unread') {
+ title = 'No unread notifications';
+ subtitle = 'Everything in your feed has been read.';
+ } else if (hasAny && filter === 'alerts') {
+ title = 'No active alerts';
+ subtitle = 'Warnings and errors will surface here when they occur.';
+ }
+
+ return (
+
+
+
{title}
+
{subtitle}
+
+ );
+}
diff --git a/frontend/src/components/UserProfileDropdown.tsx b/frontend/src/components/UserProfileDropdown.tsx
index 8a380a8b..f435323e 100644
--- a/frontend/src/components/UserProfileDropdown.tsx
+++ b/frontend/src/components/UserProfileDropdown.tsx
@@ -1,12 +1,26 @@
import { useState } from 'react';
-import { Settings, LogOut, ExternalLink, Monitor, Sun, Moon, User, Loader2 } from 'lucide-react';
+import {
+ Settings,
+ LogOut,
+ ExternalLink,
+ Monitor,
+ Sun,
+ Moon,
+ User,
+ Loader2,
+ BookOpen,
+ MessageSquare,
+ CreditCard,
+} from 'lucide-react';
+import type { LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
-import { Separator } from '@/components/ui/separator';
+import { SegmentedControl } from '@/components/ui/segmented-control';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
+import { cn } from '@/lib/utils';
import { TierBadge } from './TierBadge';
type Theme = 'light' | 'dark' | 'auto';
@@ -17,6 +31,23 @@ interface UserProfileDropdownProps {
onOpenSettings: () => void;
}
+const THEME_OPTIONS = [
+ { value: 'auto' as const, label: 'Auto', icon: Monitor },
+ { value: 'light' as const, label: 'Light', icon: Sun },
+ { value: 'dark' as const, label: 'Dark', icon: Moon },
+];
+
+function getInitials(username: string | undefined): string {
+ if (!username) return '';
+ const trimmed = username.trim();
+ if (!trimmed) return '';
+ const parts = trimmed.split(/[\s._-]+/).filter(Boolean);
+ if (parts.length >= 2) {
+ return (parts[0][0] + parts[1][0]).toUpperCase();
+ }
+ return trimmed.slice(0, 2).toUpperCase();
+}
+
export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserProfileDropdownProps) {
const { logout, user, isAdmin } = useAuth();
const { license } = useLicense();
@@ -39,134 +70,181 @@ export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserPro
}
};
+ const showBilling = license?.status === 'active' && !license?.isLifetime;
+ const initials = getInitials(user?.username);
+ const roleLabel = user?.role;
+
return (
-
-
+
+ {initials ? initials : }
-
- {/* User Info */}
-
-
-
-
-
-
-
{user?.username ?? 'admin'}
-
-
- {user?.role ?? 'admin'}
+
+ {/* Identity header */}
+
+
+
+
+
+ {initials ? (
+
+ {initials}
- ·
+ ) : (
+
+ )}
+
+
+
+ {user?.username ?? 'admin'}
+
+
+ {roleLabel ? (
+
+ {roleLabel}
+
+ ) : null}
-
-
- {/* Navigation Links */}
-
-
-
- Settings
-
- {license?.status === 'active' && !license?.isLifetime && (
-
+
+ {showBilling ? (
+
- {billingLoading ? (
-
- ) : (
-
- )}
- Billing
-
- )}
-
-
-
-
- {/* Theme Toggle */}
-
-
Theme
-
- setTheme('auto')}
- className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-xs transition-colors ${
- theme === 'auto' ? 'bg-background shadow-sm font-medium' : 'text-muted-foreground hover:text-foreground'
- }`}
- >
-
- System
-
- setTheme('light')}
- className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-xs transition-colors ${
- theme === 'light' ? 'bg-background shadow-sm font-medium' : 'text-muted-foreground hover:text-foreground'
- }`}
- >
-
- Light
-
- setTheme('dark')}
- className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-xs transition-colors ${
- theme === 'dark' ? 'bg-background shadow-sm font-medium' : 'text-muted-foreground hover:text-foreground'
- }`}
- >
-
- Dark
-
-
-
-
-
-
- {/* Documentation Links */}
-
-
+ ) : null}
+
-
- Documentation
-
-
+
-
- Feedback
-
+ external
+ />
-
+ {/* Appearance */}
+
+
+ Appearance
+
+
+
{/* Logout */}
-
-
+
-
+
Log Out
-
+
);
}
+
+interface MenuRowProps {
+ icon: LucideIcon;
+ label: string;
+ onClick?: () => void;
+ href?: string;
+ external?: boolean;
+ disabled?: boolean;
+ loading?: boolean;
+ trailingIcon?: LucideIcon;
+}
+
+function MenuRow({
+ icon: Icon,
+ label,
+ onClick,
+ href,
+ external,
+ disabled,
+ loading,
+ trailingIcon,
+}: MenuRowProps) {
+ const TrailingIcon = trailingIcon ?? (external ? ExternalLink : undefined);
+ const classes = cn(
+ 'flex w-full items-center gap-2.5 px-5 py-2.5 text-left text-sm text-stat-value transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none',
+ disabled && 'pointer-events-none opacity-50',
+ );
+
+ const leadingIcon = loading ? (
+
+ ) : (
+
+ );
+
+ const body = (
+ <>
+ {leadingIcon}
+ {label}
+ {TrailingIcon ? (
+
+ ) : null}
+ >
+ );
+
+ if (href) {
+ return (
+
+ {body}
+
+ );
+ }
+
+ return (
+
+ {body}
+
+ );
+}
diff --git a/frontend/src/components/ui/segmented-control.tsx b/frontend/src/components/ui/segmented-control.tsx
new file mode 100644
index 00000000..b959b8be
--- /dev/null
+++ b/frontend/src/components/ui/segmented-control.tsx
@@ -0,0 +1,115 @@
+import { useRef } from 'react';
+import type { KeyboardEvent } from 'react';
+import type { LucideIcon } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+export interface SegmentedControlOption {
+ value: T;
+ label: string;
+ icon?: LucideIcon;
+ badge?: string | number;
+}
+
+interface SegmentedControlProps {
+ value: T;
+ options: SegmentedControlOption[];
+ onChange: (next: T) => void;
+ ariaLabel?: string;
+ iconOnly?: boolean;
+ className?: string;
+}
+
+export function SegmentedControl({
+ value,
+ options,
+ onChange,
+ ariaLabel,
+ iconOnly,
+ className,
+}: SegmentedControlProps) {
+ const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);
+
+ const focusIndex = (index: number) => {
+ const clamped = (index + options.length) % options.length;
+ const target = buttonsRef.current[clamped];
+ const opt = options[clamped];
+ if (target && opt) {
+ target.focus();
+ onChange(opt.value);
+ }
+ };
+
+ const handleKeyDown = (event: KeyboardEvent, current: number) => {
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
+ event.preventDefault();
+ focusIndex(current + 1);
+ } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
+ event.preventDefault();
+ focusIndex(current - 1);
+ } else if (event.key === 'Home') {
+ event.preventDefault();
+ focusIndex(0);
+ } else if (event.key === 'End') {
+ event.preventDefault();
+ focusIndex(options.length - 1);
+ }
+ };
+
+ return (
+
+ {options.map((opt, index) => {
+ const active = value === opt.value;
+ const Icon = opt.icon;
+ const hasBadge = opt.badge !== undefined && opt.badge !== '';
+ const a11yLabel = iconOnly
+ ? opt.label
+ : hasBadge
+ ? `${opt.label}, ${opt.badge}`
+ : undefined;
+ return (
+ {
+ buttonsRef.current[index] = el;
+ }}
+ type="button"
+ role="radio"
+ aria-checked={active}
+ aria-label={a11yLabel}
+ title={iconOnly ? opt.label : undefined}
+ tabIndex={active ? 0 : -1}
+ onClick={() => onChange(opt.value)}
+ onKeyDown={(e) => handleKeyDown(e, index)}
+ className={cn(
+ 'flex items-center gap-1.5 rounded px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.14em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
+ active
+ ? 'bg-brand/10 text-brand'
+ : 'text-stat-subtitle hover:text-stat-value',
+ )}
+ >
+ {Icon ? : null}
+ {iconOnly ? null : {opt.label} }
+ {hasBadge ? (
+
+ {opt.badge}
+
+ ) : null}
+
+ );
+ })}
+
+ );
+}