Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,29 @@ import { RouteProgressProvider } from './context/RouteProgressContext'
import { NotFoundPage } from './pages/NotFoundPage'
import AppShell from './components/layout/AppShell'
import LandingPage from './pages/LandingPage'
import AgentsPage from './pages/AgentsPage'
import NewTaskPage from './pages/tasks/NewTaskPage'
import TaskHistoryPage from './pages/tasks/TaskHistoryPage'
import TaskDetailPage from './pages/TaskDetailPage'
import RendererDemoPage from './pages/RendererDemoPage'
import WalletPage from './pages/WalletPage'
import DashboardPage from './pages/dashboard'
import ErrorBoundary from './components/common/ErrorBoundary'
import { ProtectedRoute } from './components/auth/ProtectedRoute'
import { CommandPalette } from './components/common/CommandPalette'
import { useCommandPalette } from './hooks/useCommandPalette'
import './components/common/Toast.css'

// Lazy-loaded pages
const DashboardPage = lazy(() => import('./pages/dashboard'))
const AgentsPage = lazy(() => import('./pages/AgentsPage'))
const WalletPage = lazy(() => import('./pages/WalletPage'))
const TaskDetailPage = lazy(() => import('./pages/TaskDetailPage'))
const NewTaskPage = lazy(() => import('./pages/tasks/NewTaskPage'))
const TaskHistoryPage = lazy(() => import('./pages/tasks/TaskHistoryPage'))
const RendererDemoPage = lazy(() => import('./pages/RendererDemoPage'))

const RouteLoadingFallback: React.FC = () => (
<div style={{ padding: '24px', display: 'flex', flexDirection: 'column', gap: '16px' }}>
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonCard key={i} style={{ height: '100px' }} />
))}
</div>
)

/**
* Everything below the router.
*
Expand Down
103 changes: 103 additions & 0 deletions frontend/src/components/notifications/NotificationCenter.css
Original file line number Diff line number Diff line change
Expand Up @@ -274,3 +274,106 @@
color: var(--text-secondary);
max-width: 240px;
}

/* Filters */
.notification-panel-filters {
display: flex;
gap: 8px;
padding: 12px 20px;
border-bottom: 1px solid var(--border-subtle, #1f2630);
background: rgba(22, 27, 36, 0.3);
overflow-x: auto;
}

.filter-btn {
padding: 6px 12px;
border: 1px solid transparent;
border-radius: 20px;
background: rgba(255, 255, 255, 0.04);
color: var(--text-secondary, #8a93a3);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s ease;
}

.filter-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary, #f5f7fa);
}

.filter-btn.active {
background: rgba(56, 189, 248, 0.15);
border-color: rgba(56, 189, 248, 0.3);
color: var(--accent-cyan, #38bdf8);
}

/* Grouped Notifications */
.notification-groups {
display: flex;
flex-direction: column;
gap: 8px;
}

.notification-group {
border-radius: 8px;
overflow: hidden;
}

.group-header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.02);
border: none;
color: var(--text-secondary, #8a93a3);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
text-align: left;
transition: all 0.2s ease;
border-radius: 8px;
}

.group-header:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-primary, #f5f7fa);
}

.group-title {
display: flex;
align-items: center;
gap: 8px;
}

.group-count {
font-size: 0.75rem;
opacity: 0.6;
}

.group-mark-read {
display: inline-flex;
align-items: center;
gap: 4px;
background: transparent;
border: none;
color: var(--accent-cyan, #38bdf8);
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
}

.group-mark-read:hover {
background: rgba(56, 189, 248, 0.1);
}

.group-items {
display: flex;
flex-direction: column;
gap: 6px;
padding: 0 0 6px 0;
}
139 changes: 120 additions & 19 deletions frontend/src/components/notifications/NotificationCenter.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { CheckCheck, Inbox } from 'lucide-react';
import { CheckCheck, Inbox, Filter } from 'lucide-react';
import { useNotifications } from '../../hooks/useNotifications';
import NotificationItem from './NotificationItem';
import './NotificationCenter.css';
Expand All @@ -11,13 +11,61 @@ interface NotificationCenterProps {
anchorRef?: React.RefObject<HTMLElement>;
}

type NotificationFilter = 'all' | 'task' | 'payment' | 'agent' | 'system';

export const NotificationCenter: React.FC<NotificationCenterProps> = ({
isOpen,
onClose,
anchorRef,
}) => {
const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotifications();
const panelRef = useRef<HTMLDivElement>(null);
const [filter, setFilter] = useState<NotificationFilter>('all');
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['unread']));

const filteredNotifications = useMemo(() => {
return filter === 'all'
? notifications
: notifications.filter(n => n.type === filter);
}, [notifications, filter]);

const groupedNotifications = useMemo(() => {
const groups: Record<string, typeof notifications> = {
unread: [],
read: [],
};

filteredNotifications.forEach(notif => {
if (notif.read) {
groups.read.push(notif);
} else {
groups.unread.push(notif);
}
});

return groups;
}, [filteredNotifications]);

const toggleGroup = (groupKey: string) => {
setExpandedGroups(prev => {
const next = new Set(prev);
if (next.has(groupKey)) {
next.delete(groupKey);
} else {
next.add(groupKey);
}
return next;
});
};

const markGroupAsRead = (groupKey: string) => {
const group = groupedNotifications[groupKey as keyof typeof groupedNotifications];
group?.forEach(notif => {
if (!notif.read) {
markAsRead(notif.id);
}
});
};

useEffect(() => {
if (!isOpen) return;
Expand Down Expand Up @@ -65,10 +113,10 @@ export const NotificationCenter: React.FC<NotificationCenterProps> = ({
{/* Header */}
<div className="notification-panel-header">
<div className="notification-panel-title-group">
<span className="notification-panel-title">Notifications</span>
<span className="notification-panel-title">Inbox</span>
{unreadCount > 0 && (
<span className="notification-unread-count-pill" data-testid="panel-unread-badge">
{unreadCount} new
{unreadCount}
</span>
)}
</div>
Expand All @@ -82,33 +130,86 @@ export const NotificationCenter: React.FC<NotificationCenterProps> = ({
data-testid="mark-all-read-btn"
>
<CheckCheck size={14} />
<span>Mark all as read</span>
</button>
)}
</div>

{/* Filters */}
<div className="notification-panel-filters">
{(['all', 'task', 'payment', 'agent', 'system'] as NotificationFilter[]).map(f => (
<button
key={f}
type="button"
className={`filter-btn ${filter === f ? 'active' : ''}`}
onClick={() => setFilter(f)}
aria-pressed={filter === f}
>
{f === 'all' ? 'All' : f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>

{/* Body */}
<div className="notification-panel-body">
{notifications.length === 0 ? (
{filteredNotifications.length === 0 ? (
<div className="notification-empty-state" data-testid="notification-empty-state">
<div className="empty-state-icon-wrapper">
<Inbox size={28} className="empty-state-icon" />
</div>
<p className="empty-state-title">No notifications yet</p>
<p className="empty-state-subtitle">We'll alert you when tasks update or payments settle.</p>
<p className="empty-state-title">No notifications</p>
<p className="empty-state-subtitle">Stay tuned for updates on your tasks and payments.</p>
</div>
) : (
<div className="notification-list" role="feed" aria-label="Notifications list">
<AnimatePresence initial={false}>
{notifications.map(notification => (
<NotificationItem
key={notification.id}
notification={notification}
onMarkAsRead={markAsRead}
onClose={onClose}
/>
))}
</AnimatePresence>
<div className="notification-groups" role="feed">
{Object.entries(groupedNotifications).map(([groupKey, group]) => (
group.length > 0 && (
<div key={groupKey} className="notification-group">
<button
type="button"
className="group-header"
onClick={() => toggleGroup(groupKey)}
aria-expanded={expandedGroups.has(groupKey)}
>
<span className="group-title">
{groupKey === 'unread' ? 'Unread' : 'Read'}
<span className="group-count">({group.length})</span>
</span>
{group.some(n => !n.read) && groupKey === 'unread' && (
<button
type="button"
className="group-mark-read"
onClick={(e) => {
e.stopPropagation();
markGroupAsRead(groupKey);
}}
aria-label="Mark group as read"
>
<CheckCheck size={14} />
</button>
)}
</button>
<AnimatePresence>
{expandedGroups.has(groupKey) && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="group-items"
>
{group.map(notification => (
<NotificationItem
key={notification.id}
notification={notification}
onMarkAsRead={markAsRead}
onClose={onClose}
/>
))}
</motion.div>
)}
</AnimatePresence>
</div>
)
))}
</div>
)}
</div>
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/components/tasks/TaskTimeline.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,35 @@
box-shadow: none;
}

/* ─── Row actions (on hover) ────────────────────────────────────── */

.rowActions {
display: flex;
gap: 6px;
align-items: center;
}

.actionBtn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
background: transparent;
border: 1px solid var(--panel-border);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
padding: 0;
}

.actionBtn:hover {
background: rgba(56, 189, 248, 0.1);
color: var(--accent);
border-color: rgba(56, 189, 248, 0.3);
}

/* ─── Execution bar ─────────────────────────────────────────────── */

.execBar {
Expand Down
Loading