+ {/* Agent pills row */}
+
+ {/* All option */}
+
+
+ {/* Agent pills */}
+ {JOB_DEFINITIONS.map((job) => {
+ const isSelected = selectedAgent === job.processName;
+ const isRunning = getProcessStatus(job.processName);
+
+ return (
+
+ );
+ })}
+
+
+ {/* Search and errors toggle row */}
+
+
+ );
+};
+
+export default LogFilterBar;
diff --git a/web/components/TopBar.tsx b/web/components/TopBar.tsx
index 9c93833f..d6bf6e24 100644
--- a/web/components/TopBar.tsx
+++ b/web/components/TopBar.tsx
@@ -1,9 +1,11 @@
import React from 'react';
import { Search, Bell, Wifi, WifiOff } from 'lucide-react';
-import { useStore } from '../store/useStore';
+import { useStore } from '../store/useStore.js';
+import { useActivityFeed } from '../hooks/useActivityFeed.js';
const TopBar: React.FC = () => {
- const { projectName } = useStore();
+ const { projectName, setActivityCenterOpen } = useStore();
+ const { hasUnread } = useActivityFeed();
const isLive = true; // Mock connection status
return (
@@ -13,9 +15,9 @@ const TopBar: React.FC = () => {
{isLive ?
:
}
{isLive ? 'Online' : 'Offline'}
@@ -35,12 +37,17 @@ const TopBar: React.FC = () => {
{/* Actions */}
-
-
+
AD
diff --git a/web/hooks/useActivityFeed.ts b/web/hooks/useActivityFeed.ts
new file mode 100644
index 00000000..ba2402ee
--- /dev/null
+++ b/web/hooks/useActivityFeed.ts
@@ -0,0 +1,250 @@
+import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
+import { useStore } from '../store/useStore.js';
+import { fetchLogs } from '../api.js';
+import { WEB_JOB_REGISTRY } from '../utils/jobs.js';
+import type { IStatusSnapshot } from '@shared/types';
+
+export interface IActivityEvent {
+ id: string;
+ type: 'agent_completed' | 'agent_failed' | 'schedule_fired' | 'automation_paused' | 'automation_resumed' | 'pr_opened';
+ agent?: string;
+ duration?: string;
+ prd?: string;
+ error?: string;
+ prNumber?: number;
+ prTitle?: string;
+ ts: Date;
+}
+
+interface IDayGroup {
+ label: string;
+ events: IActivityEvent[];
+}
+
+const MAX_EVENTS = 50;
+const LOG_LINES_TO_FETCH = 200;
+
+function generateEventId(): string {
+ return Math.random().toString(36).substring(2, 10) + Date.now().toString(36);
+}
+
+function formatDuration(startTime: number): string {
+ const seconds = Math.floor((Date.now() - startTime) / 1000);
+ if (seconds < 60) return `${seconds}s`;
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m`;
+ const hours = Math.floor(minutes / 60);
+ return `${hours}h`;
+}
+
+function getDayLabel(date: Date): string {
+ const today = new Date();
+ const yesterday = new Date(today);
+ yesterday.setDate(yesterday.getDate() - 1);
+
+ const isToday = date.toDateString() === today.toDateString();
+ const isYesterday = date.toDateString() === yesterday.toDateString();
+
+ if (isToday) return 'Today';
+ if (isYesterday) return 'Yesterday';
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+}
+
+function parseLogEntryForEvent(logLine: string, agentName: string): IActivityEvent | null {
+ const tsMatch = logLine.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})/);
+ const timestamp = tsMatch ? new Date(tsMatch[1]) : new Date();
+
+ if (logLine.includes('[ERROR]') || logLine.includes('[error]') || /\bError:/.test(logLine) || /\bFailed\b/.test(logLine)) {
+ const errorMatch = logLine.match(/(?:\[ERROR\]|Error:|Failed)\s*(.+)/i);
+ return {
+ id: generateEventId(),
+ type: 'agent_failed',
+ agent: agentName,
+ error: errorMatch?.[1]?.substring(0, 100) || 'Unknown error',
+ ts: timestamp,
+ };
+ }
+
+ if (logLine.includes('completed') || logLine.includes('Completed') || logLine.includes('finished') || logLine.includes('Finished')) {
+ const prdMatch = logLine.match(/PRD[-\s]*(\w+)/i);
+ const durationMatch = logLine.match(/(?:duration|took)[:\s]*(\d+[hms]+)/i);
+ return {
+ id: generateEventId(),
+ type: 'agent_completed',
+ agent: agentName,
+ duration: durationMatch?.[1],
+ prd: prdMatch?.[1],
+ ts: timestamp,
+ };
+ }
+
+ return null;
+}
+
+export function useActivityFeed(): {
+ events: IActivityEvent[];
+ groupedEvents: IDayGroup[];
+ hasUnread: boolean;
+ markAsRead: () => void;
+} {
+ const status = useStore((s) => s.status);
+ const activityCenterOpen = useStore((s) => s.activityCenterOpen);
+ const [events, setEvents] = useState
([]);
+ const [lastReadTimestamp, setLastReadTimestamp] = useState(() => {
+ const saved = typeof localStorage !== 'undefined' ? localStorage.getItem('nw-activity-last-read') : null;
+ return saved ? new Date(saved) : new Date(0);
+ });
+ const previousStatusRef = useRef(null);
+ const runningStartTimesRef = useRef