From e999f71049960b6d38bae604873d85bb5204dd14 Mon Sep 17 00:00:00 2001 From: devjayy43 Date: Mon, 31 Aug 2026 08:40:56 +0100 Subject: [PATCH] feat: Polish notification center, implement route-level code splitting, and add task history row actions - #412: Group notifications by read/unread status with per-group mark-as-read - Add category filtering (All, Task, Payment, Agent, System) - Collapsible notification groups with count badges - #411: Implement route-level code splitting with React.lazy and Suspense - Lazy-load Dashboard, Agents, Wallet, TaskDetail, NewTask, TaskHistory, RendererDemo - Add RouteLoadingFallback component with skeleton UI - #409: Add row hover actions to task history (Resume, Duplicate, Export) - Actions visible on row hover with proper styling - Export task as JSON file functionality --- frontend/src/App.tsx | 97 ++++++------ .../notifications/NotificationCenter.css | 103 +++++++++++++ .../notifications/NotificationCenter.tsx | 139 +++++++++++++++--- .../components/tasks/TaskTimeline.module.css | 29 ++++ .../src/components/tasks/TaskTimeline.tsx | 61 ++++++++ 5 files changed, 363 insertions(+), 66 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6bed989f..b6f38077 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { Suspense, lazy } from 'react' import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' import { I18nextProvider } from 'react-i18next' import i18n from './i18n' @@ -9,20 +9,31 @@ import { ThemeProvider } from './context/ThemeContext' 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/common/ProtectedRoute' import { CommandPalette } from './components/common/CommandPalette' import { useCommandPalette } from './hooks/useCommandPalette' -import { ProtectedRoute } from './components/auth/ProtectedRoute' +import { ProtectedRoute as AuthProtectedRoute } from './components/auth/ProtectedRoute' +import { Skeleton, SkeletonCard, SkeletonTable } from './components/common/Skeleton' 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 that needs router context lives here, so `` (mounted by * `App` below) is already in place before `useCommandPalette` calls @@ -34,56 +45,49 @@ const AppContent: React.FC = () => { } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + + + + ) } -// Lives INSIDE : useCommandPalette() calls useNavigate(), which -// throws the "may be used only in the context of a " invariant when -// rendered above it. const RoutedContent: React.FC = () => { const { isOpen, closePalette, search, recentSearches } = useCommandPalette() return ( <> - } /> - - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - {import.meta.env.DEV && ( - } /> - )} - } /> - + }> + + + } /> + + } /> + + } /> + + } /> + + } /> + + } /> + {import.meta.env.DEV && ( + } /> + )} + } /> + + } /> } /> @@ -94,7 +98,6 @@ const RoutedContent: React.FC = () => { onSearch={search} recentSearches={recentSearches} onRecentSearchClick={(query) => { - // Trigger search with the recent query search(query) }} /> diff --git a/frontend/src/components/notifications/NotificationCenter.css b/frontend/src/components/notifications/NotificationCenter.css index e082eb98..99972114 100644 --- a/frontend/src/components/notifications/NotificationCenter.css +++ b/frontend/src/components/notifications/NotificationCenter.css @@ -274,3 +274,106 @@ color: var(--text-secondary, #8a93a3); 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 151f15b2..63dd6eca 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 771da990..4a9b072d 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 { @@ -131,6 +134,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); @@ -155,12 +159,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 */}
@@ -229,6 +257,39 @@ const TimelineEntry: React.FC = ({ > + + {/* Row actions (visible on hover) */} + {showActions && ( +
+ + + +
+ )}