diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 18e4f17d..c73d2ea5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = () => ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+) + /** * Everything below the router. * diff --git a/frontend/src/components/notifications/NotificationCenter.css b/frontend/src/components/notifications/NotificationCenter.css index 96e9c0ba..9acd10d3 100644 --- a/frontend/src/components/notifications/NotificationCenter.css +++ b/frontend/src/components/notifications/NotificationCenter.css @@ -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; +} diff --git a/frontend/src/components/notifications/NotificationCenter.tsx b/frontend/src/components/notifications/NotificationCenter.tsx index 8a84a5a2..0a95d189 100644 --- a/frontend/src/components/notifications/NotificationCenter.tsx +++ b/frontend/src/components/notifications/NotificationCenter.tsx @@ -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'; @@ -11,6 +11,8 @@ interface NotificationCenterProps { anchorRef?: React.RefObject; } +type NotificationFilter = 'all' | 'task' | 'payment' | 'agent' | 'system'; + export const NotificationCenter: React.FC = ({ isOpen, onClose, @@ -18,6 +20,52 @@ export const NotificationCenter: React.FC = ({ }) => { const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotifications(); const panelRef = useRef(null); + const [filter, setFilter] = useState('all'); + const [expandedGroups, setExpandedGroups] = useState>(new Set(['unread'])); + + const filteredNotifications = useMemo(() => { + return filter === 'all' + ? notifications + : notifications.filter(n => n.type === filter); + }, [notifications, filter]); + + const groupedNotifications = useMemo(() => { + const groups: Record = { + 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; @@ -65,10 +113,10 @@ export const NotificationCenter: React.FC = ({ {/* Header */}
- Notifications + Inbox {unreadCount > 0 && ( - {unreadCount} new + {unreadCount} )}
@@ -82,33 +130,86 @@ export const NotificationCenter: React.FC = ({ data-testid="mark-all-read-btn" > - Mark all as read )}
+ {/* Filters */} +
+ {(['all', 'task', 'payment', 'agent', 'system'] as NotificationFilter[]).map(f => ( + + ))} +
+ {/* Body */}
- {notifications.length === 0 ? ( + {filteredNotifications.length === 0 ? (
-

No notifications yet

-

We'll alert you when tasks update or payments settle.

+

No notifications

+

Stay tuned for updates on your tasks and payments.

) : ( -
- - {notifications.map(notification => ( - - ))} - +
+ {Object.entries(groupedNotifications).map(([groupKey, group]) => ( + group.length > 0 && ( +
+ + )} + + + {expandedGroups.has(groupKey) && ( + + {group.map(notification => ( + + ))} + + )} + +
+ ) + ))}
)}
diff --git a/frontend/src/components/tasks/TaskTimeline.module.css b/frontend/src/components/tasks/TaskTimeline.module.css index d2037ea5..4b073d6e 100644 --- a/frontend/src/components/tasks/TaskTimeline.module.css +++ b/frontend/src/components/tasks/TaskTimeline.module.css @@ -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 { diff --git a/frontend/src/components/tasks/TaskTimeline.tsx b/frontend/src/components/tasks/TaskTimeline.tsx index 584f6be1..68a2d3b6 100644 --- a/frontend/src/components/tasks/TaskTimeline.tsx +++ b/frontend/src/components/tasks/TaskTimeline.tsx @@ -9,6 +9,9 @@ import { ChevronRight, ExternalLink, AlertCircle, + Copy, + Download, + Play, } from 'lucide-react'; import type { TaskResponse } from '../../types/api'; import { @@ -147,6 +150,7 @@ const TimelineEntry: React.FC = ({ }) => { const navigate = useNavigate(); const [errorExpanded, setErrorExpanded] = useState(false); + const [showActions, setShowActions] = useState(false); const taskId = task.taskId || task.id || ''; const meta = getStatusMeta(task.status); @@ -171,12 +175,36 @@ const TimelineEntry: React.FC = ({ navigate(`/tasks/${taskId}`); }; + const handleResume = (e: React.MouseEvent) => { + e.stopPropagation(); + navigate('/tasks/new', { state: { previousTask: task } }); + }; + + const handleDuplicate = (e: React.MouseEvent) => { + e.stopPropagation(); + navigate('/tasks/new', { state: { duplicateFrom: task } }); + }; + + const handleExport = (e: React.MouseEvent) => { + e.stopPropagation(); + const json = JSON.stringify(task, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `task-${taskId}.json`; + a.click(); + URL.revokeObjectURL(url); + }; + return (
setShowActions(true)} + onMouseLeave={() => setShowActions(false)} > {/* Timeline connector */}
@@ -245,6 +273,39 @@ const TimelineEntry: React.FC = ({ > + + {/* Row actions (visible on hover) */} + {showActions && ( +
+ + + +
+ )}