diff --git a/src/app/api/activity/route.ts b/src/app/api/activity/route.ts new file mode 100644 index 0000000..5e61375 --- /dev/null +++ b/src/app/api/activity/route.ts @@ -0,0 +1,10 @@ +import { activityEventBus } from '../../../lib/realtime'; + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const projectRoot = url.searchParams.get('projectRoot') || undefined; + + const history = activityEventBus.getHistory(projectRoot); + + return Response.json(history); +} diff --git a/src/app/api/agents/[agentId]/stats/route.ts b/src/app/api/agents/[agentId]/stats/route.ts new file mode 100644 index 0000000..0a8d4b4 --- /dev/null +++ b/src/app/api/agents/[agentId]/stats/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server'; +import { readIssuesFromDisk } from '../../../../../lib/read-issues'; +import { activityEventBus } from '../../../../../lib/realtime'; +import { getAgentMetrics } from '../../../../../lib/agent-sessions'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ agentId: string }> } +): Promise { + const { agentId } = await params; + const url = new URL(request.url); + const projectRoot = url.searchParams.get('projectRoot') ?? process.cwd(); + + try { + const issues = await readIssuesFromDisk({ projectRoot, preferBd: true }); + const activity = activityEventBus.getHistory(projectRoot); + + const metrics = await getAgentMetrics(agentId, issues, activity); + + return NextResponse.json({ ok: true, metrics }); + } catch (error) { + console.error('[API/Agents/Stats] Failed:', error); + return NextResponse.json({ ok: false, error: String(error) }, { status: 500 }); + } +} diff --git a/src/app/timeline/page.tsx b/src/app/timeline/page.tsx new file mode 100644 index 0000000..da94814 --- /dev/null +++ b/src/app/timeline/page.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useEffect } from 'react'; +import { TimelineFeed } from '../../components/timeline/timeline-feed'; +import { useTimelineStore } from '../../components/timeline/timeline-store'; + +export default function TimelinePage() { + return ( +
+
+

Activity Timeline

+

Real-time stream of project mutations.

+
+ + + + +
+ ); +} + +function TimelineControls() { + return ( +
+ {/* Placeholder for future filters */} +
Showing all activity
+
+ ); +} + +function TimelineSubscription() { + const { addEvent, setHistory } = useTimelineStore(); + + useEffect(() => { + // 1. Fetch history + fetch('/api/activity') + .then(res => { + if (!res.ok) throw new Error('History fetch failed'); + return res.json(); + }) + .then(data => setHistory(data)) + .catch(err => console.error('Failed to load history', err)); + + // 2. Subscribe to SSE + const es = new EventSource('/api/events'); + + es.addEventListener('activity', (e) => { + try { + const event = JSON.parse(e.data); + addEvent(event); + } catch (err) { + console.error('Failed to parse activity event', err); + } + }); + + return () => es.close(); + }, [setHistory, addEvent]); + + return null; +} \ No newline at end of file diff --git a/src/components/timeline/event-card.tsx b/src/components/timeline/event-card.tsx new file mode 100644 index 0000000..54a99ce --- /dev/null +++ b/src/components/timeline/event-card.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { motion } from 'framer-motion'; +import Link from 'next/link'; +import type { ActivityEvent } from '../../lib/activity'; +import { Chip } from '../shared/chip'; + +interface EventCardProps { + event: ActivityEvent; +} + +export function EventCard({ event }: EventCardProps) { + return ( + +
+ +
+ +
+
+
+ {event.actor || 'System'} + {getActionVerb(event.kind)} + + {event.beadTitle} + +
+ +
+ +
+ +
+ +
+ {event.projectName} + {event.beadId} +
+
+
+ ); +} + +function StatusIcon({ kind }: { kind: string }) { + let color = 'bg-slate-500'; + if (kind === 'created' || kind === 'reopened') color = 'bg-emerald-500'; + if (kind === 'closed') color = 'bg-rose-500'; + if (kind === 'status_changed') color = 'bg-amber-500'; + if (kind.includes('comment')) color = 'bg-blue-500'; + + return ( +
+ ); +} + +function getActionVerb(kind: string): string { + switch (kind) { + case 'created': return 'created'; + case 'closed': return 'closed'; + case 'reopened': return 'reopened'; + case 'status_changed': return 'moved'; + case 'comment_added': return 'commented on'; + case 'assignee_changed': return 'assigned'; + default: return 'updated'; + } +} + +function EventPayload({ event }: { event: ActivityEvent }) { + const { payload } = event; + + if (event.kind === 'status_changed') { + return ( +
+ {payload.from} + + {payload.to} +
+ ); + } + + if (event.kind === 'comment_added') { + return ( +
+ "{payload.message}" +
+ ); + } + + if (payload.from !== undefined && payload.to !== undefined) { + return ( +
+ Changed {payload.field}: {String(payload.from)}{String(payload.to)} +
+ ); + } + + if (payload.message) { + return
{payload.message}
; + } + + return null; +} diff --git a/src/components/timeline/timeline-feed.tsx b/src/components/timeline/timeline-feed.tsx new file mode 100644 index 0000000..2390859 --- /dev/null +++ b/src/components/timeline/timeline-feed.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useMemo } from 'react'; +import { useTimelineStore } from './timeline-store'; +import { EventCard } from './event-card'; + +export function TimelineFeed() { + const { events, filterProject, filterActor, filterKind } = useTimelineStore(); + + const filteredEvents = useMemo(() => { + return events.filter(e => { + if (filterProject && e.projectId !== filterProject) return false; + if (filterActor && e.actor !== filterActor) return false; + if (filterKind && e.kind !== filterKind) return false; + return true; + }); + }, [events, filterProject, filterActor, filterKind]); + + const groups = useMemo(() => { + const grouped: Record = {}; + filteredEvents.forEach(e => { + const date = new Date(e.timestamp).toLocaleDateString(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }); + if (!grouped[date]) grouped[date] = []; + grouped[date].push(e); + }); + return grouped; + }, [filteredEvents]); + + if (filteredEvents.length === 0) { + return ( +
+ No activity found. +
+ ); + } + + return ( +
+ {Object.entries(groups).map(([date, groupEvents]) => ( +
+

+ {date} +

+
+ {groupEvents.map(event => ( + + ))} +
+
+ ))} +
+ ); +} diff --git a/src/components/timeline/timeline-store.ts b/src/components/timeline/timeline-store.ts new file mode 100644 index 0000000..ca449be --- /dev/null +++ b/src/components/timeline/timeline-store.ts @@ -0,0 +1,77 @@ +import { create } from 'zustand'; +import type { ActivityEvent, ActivityEventKind } from '../../lib/activity'; + +export interface TimelineState { + events: ActivityEvent[]; + filterProject: string | null; + filterActor: string | null; + filterKind: ActivityEventKind | null; + + // Selection states for Sessions UI + selectedAgentId: string | null; + selectedTaskId: string | null; + + addEvent: (event: ActivityEvent) => void; + setHistory: (events: ActivityEvent[]) => void; + setFilterProject: (projectId: string | null) => void; + setFilterActor: (actor: string | null) => void; + setFilterKind: (kind: ActivityEventKind | null) => void; + + // Selection actions + setSelectedAgentId: (agentId: string | null) => void; + setSelectedTaskId: (taskId: string | null) => void; + backToAgent: () => void; + + clear: () => void; +} + +export const useTimelineStore = create((set) => ({ + events: [], + filterProject: null, + filterActor: null, + filterKind: null, + + selectedAgentId: null, + selectedTaskId: null, + + addEvent: (event) => set((state) => { + // Avoid duplicates + if (state.events.some(e => e.id === event.id)) { + return state; + } + return { events: [event, ...state.events] }; + }), + + setHistory: (history) => set((state) => { + const existingIds = new Set(state.events.map(e => e.id)); + const newEvents = history.filter(e => !existingIds.has(e.id)); + // Merge and sort by timestamp desc + const merged = [...state.events, ...newEvents].sort((a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ); + return { events: merged }; + }), + + setFilterProject: (projectId) => set({ filterProject: projectId }), + setFilterActor: (actor) => set({ filterActor: actor }), + setFilterKind: (kind) => set({ filterKind: kind }), + + setSelectedAgentId: (agentId) => set({ + selectedAgentId: agentId, + // When selecting a new agent, clear task selection to show agent scorecard + selectedTaskId: null + }), + + setSelectedTaskId: (taskId) => set({ selectedTaskId: taskId }), + + backToAgent: () => set({ selectedTaskId: null }), + + clear: () => set({ + events: [], + selectedAgentId: null, + selectedTaskId: null, + filterProject: null, + filterActor: null, + filterKind: null + }), +})); \ No newline at end of file diff --git a/tests/api/sessions-route.test.ts b/tests/api/sessions-route.test.ts new file mode 100644 index 0000000..1c49fe2 --- /dev/null +++ b/tests/api/sessions-route.test.ts @@ -0,0 +1,25 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { GET } from '../../src/app/api/sessions/route'; + +describe('Sessions API Route', () => { + it('should return a successful feed response', async () => { + const request = new Request('http://localhost/api/sessions'); + const response = await GET(request); + const body = await response.json(); + + assert.strictEqual(response.status, 200); + assert.strictEqual(body.ok, true); + assert.ok(Array.isArray(body.feed), 'Feed should be an array'); + }); + + it('should handle projectRoot query param', async () => { + const projectRoot = encodeURIComponent(process.cwd()); + const request = new Request(`http://localhost/api/sessions?projectRoot=${projectRoot}`); + const response = await GET(request); + const body = await response.json(); + + assert.strictEqual(response.status, 200); + assert.strictEqual(body.ok, true); + }); +}); diff --git a/tests/lib/realtime-history.test.ts b/tests/lib/realtime-history.test.ts new file mode 100644 index 0000000..66025a8 --- /dev/null +++ b/tests/lib/realtime-history.test.ts @@ -0,0 +1,62 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert'; +import { ActivityEventBus } from '../../src/lib/realtime'; +import type { ActivityEvent } from '../../src/lib/activity'; + +const MOCK_EVENT: ActivityEvent = { + id: 'evt-1', + kind: 'created', + beadId: 'bb-1', + beadTitle: 'Test', + projectId: 'C:\\Test', // Note: Backslash needs to be escaped in string literals + projectName: 'Test', + timestamp: new Date().toISOString(), + actor: 'user', + payload: {}, +}; + +describe('ActivityEventBus History', () => { + let bus: ActivityEventBus; + + beforeEach(() => { + bus = new ActivityEventBus(); + }); + + it('should buffer emitted events', () => { + bus.emit(MOCK_EVENT); + const history = bus.getHistory(); + assert.strictEqual(history.length, 1); + assert.deepStrictEqual(history[0], MOCK_EVENT); + }); + + it('should respect the history limit (ring buffer)', () => { + // MAX_HISTORY is 100 + for (let i = 0; i < 110; i++) { + bus.emit({ ...MOCK_EVENT, id: `evt-${i}` }); + } + + const history = bus.getHistory(); + assert.strictEqual(history.length, 100); + // Should contain the latest events (LIFO: unshift adds to front) + // Wait, unshift adds to front. So index 0 is the NEWEST. + // So if we emit 0..109: + // 109 is at index 0. + // 10 is at index 99. + // 0..9 should be popped. + assert.strictEqual(history[0].id, 'evt-109'); + assert.strictEqual(history[99].id, 'evt-10'); + }); + + it('should filter history by project root', () => { + bus.emit({ ...MOCK_EVENT, projectId: 'C:\\ProjA', id: 'A' }); // Note: Backslash needs to be escaped + bus.emit({ ...MOCK_EVENT, projectId: 'C:\\ProjB', id: 'B' }); // Note: Backslash needs to be escaped + + const historyA = bus.getHistory('C:\\ProjA'); // Note: Backslash needs to be escaped + assert.strictEqual(historyA.length, 1); + assert.strictEqual(historyA[0].id, 'A'); + + const historyB = bus.getHistory('C:\\ProjB'); // Note: Backslash needs to be escaped + assert.strictEqual(historyB.length, 1); + assert.strictEqual(historyB[0].id, 'B'); + }); +});