From b860bc100df53a3ec85d6b71904b63c0c9f56032 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 20:10:39 +0100 Subject: [PATCH] feat: Add Radar page with nav item, filters, table, and tests - Adds "Radar" nav item to AppLayout between Agents and Inbox - Creates /radar route with validateSearch for timeRange/status/initiativeId/mode filters - Summary stat cards for questions, messages, subagents, compactions aggregates - Agent activity table with client-side sorting on all 10 columns (default: started desc) - Real-time SSE updates via useLiveUpdates invalidating agent namespace - Loading skeleton (5 rows) and empty state messaging - Non-zero metric cells show cursor-pointer for future drilldown dialogs - 12-test suite covering rendering, sorting, filtering, nav, and states Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/layouts/AppLayout.tsx | 1 + apps/web/src/routes/__tests__/radar.test.tsx | 299 +++++++++++++++++ apps/web/src/routes/radar.tsx | 327 +++++++++++++++++++ 3 files changed, 627 insertions(+) create mode 100644 apps/web/src/routes/__tests__/radar.test.tsx create mode 100644 apps/web/src/routes/radar.tsx diff --git a/apps/web/src/layouts/AppLayout.tsx b/apps/web/src/layouts/AppLayout.tsx index 4a2f9b3..c65167b 100644 --- a/apps/web/src/layouts/AppLayout.tsx +++ b/apps/web/src/layouts/AppLayout.tsx @@ -10,6 +10,7 @@ const navItems = [ { label: 'HQ', to: '/hq', badgeKey: null }, { label: 'Initiatives', to: '/initiatives', badgeKey: null }, { label: 'Agents', to: '/agents', badgeKey: 'running' as const }, + { label: 'Radar', to: '/radar', badgeKey: null }, { label: 'Inbox', to: '/inbox', badgeKey: 'questions' as const }, { label: 'Settings', to: '/settings', badgeKey: null }, ] as const diff --git a/apps/web/src/routes/__tests__/radar.test.tsx b/apps/web/src/routes/__tests__/radar.test.tsx new file mode 100644 index 0000000..5472ff7 --- /dev/null +++ b/apps/web/src/routes/__tests__/radar.test.tsx @@ -0,0 +1,299 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { render, screen, fireEvent, within } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' + +type AgentRadarRow = { + id: string + name: string + mode: string + status: string + initiativeId: string | null + initiativeName: string | null + taskId: string | null + taskName: string | null + createdAt: string + questionsCount: number + messagesCount: number + subagentsCount: number + compactionsCount: number +} + +// --- Hoisted mocks --- +const mockListForRadarUseQuery = vi.hoisted(() => vi.fn()) +const mockListInitiativesUseQuery = vi.hoisted(() => vi.fn()) +const mockListAgentsUseQuery = vi.hoisted(() => vi.fn()) +const mockNavigate = vi.hoisted(() => vi.fn()) +const mockUseSearch = vi.hoisted(() => + vi.fn().mockReturnValue({ + timeRange: '24h', + status: 'all', + initiativeId: undefined, + mode: 'all', + }) +) + +vi.mock('@/lib/trpc', () => ({ + trpc: { + agent: { + listForRadar: { useQuery: mockListForRadarUseQuery }, + }, + listInitiatives: { useQuery: mockListInitiativesUseQuery }, + listAgents: { useQuery: mockListAgentsUseQuery }, + }, +})) + +vi.mock('@/hooks', () => ({ + useLiveUpdates: vi.fn(), + LiveUpdateRule: undefined, +})) + +vi.mock('@/components/ThemeToggle', () => ({ + ThemeToggle: () => null, +})) + +vi.mock('@/components/HealthDot', () => ({ + HealthDot: () => null, +})) + +vi.mock('@/components/NavBadge', () => ({ + NavBadge: () => null, +})) + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: () => () => ({ component: null }), + useNavigate: () => mockNavigate, + useSearch: mockUseSearch, + Link: ({ + to, + search, + children, + }: { + to: string + search?: Record + children: React.ReactNode | ((props: { isActive: boolean }) => React.ReactNode) + }) => { + const params = search ? new URLSearchParams(search).toString() : '' + const href = params ? `${to}?${params}` : to + const content = typeof children === 'function' ? children({ isActive: false }) : children + return {content} + }, +})) + +// Import after mocks +import { RadarPage } from '../radar' +import { AppLayout } from '../../layouts/AppLayout' + +function makeAgent(overrides?: Partial): AgentRadarRow { + return { + id: 'agent-1', + name: 'jolly-penguin', + mode: 'execute', + status: 'running', + initiativeId: null, + initiativeName: null, + taskId: null, + taskName: null, + createdAt: new Date(Date.now() - 3600_000).toISOString(), + questionsCount: 0, + messagesCount: 0, + subagentsCount: 0, + compactionsCount: 0, + ...overrides, + } +} + +describe('RadarPage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListInitiativesUseQuery.mockReturnValue({ data: [], isLoading: false }) + mockUseSearch.mockReturnValue({ + timeRange: '24h', + status: 'all', + initiativeId: undefined, + mode: 'all', + }) + }) + + it('renders "Radar" heading', () => { + mockListForRadarUseQuery.mockReturnValue({ data: [], isLoading: false }) + render() + expect(screen.getByRole('heading', { name: /radar/i })).toBeInTheDocument() + }) + + it('renders 4 summary stat cards with correct aggregated values', () => { + // Use distinct totals; scope number checks to stat card containers to avoid table collisions + const agents = [ + makeAgent({ id: 'a1', questionsCount: 3, messagesCount: 10, subagentsCount: 2, compactionsCount: 1 }), + makeAgent({ id: 'a2', questionsCount: 4, messagesCount: 5, subagentsCount: 1, compactionsCount: 3 }), + ] + mockListForRadarUseQuery.mockReturnValue({ data: agents, isLoading: false }) + render() + + // Verify labels exist + expect(screen.getByText('Total Questions Asked')).toBeInTheDocument() + expect(screen.getByText('Total Inter-Agent Messages')).toBeInTheDocument() + expect(screen.getByText('Total Subagent Spawns')).toBeInTheDocument() + expect(screen.getByText('Total Compaction Events')).toBeInTheDocument() + + // Verify aggregated totals by scoping to the stat card's container + // Total Questions: 3+4=7 + const questionsContainer = screen.getByText('Total Questions Asked').parentElement! + expect(questionsContainer.querySelector('.text-3xl')).toHaveTextContent('7') + // Total Messages: 10+5=15 + const messagesContainer = screen.getByText('Total Inter-Agent Messages').parentElement! + expect(messagesContainer.querySelector('.text-3xl')).toHaveTextContent('15') + }) + + it('table renders one row per agent', () => { + const agents = [ + makeAgent({ id: 'a1', name: 'agent-one' }), + makeAgent({ id: 'a2', name: 'agent-two' }), + makeAgent({ id: 'a3', name: 'agent-three' }), + ] + mockListForRadarUseQuery.mockReturnValue({ data: agents, isLoading: false }) + render() + + const tbody = document.querySelector('tbody')! + const rows = within(tbody).getAllByRole('row') + expect(rows).toHaveLength(3) + }) + + it('default sort: newest first', () => { + const older = makeAgent({ id: 'a1', name: 'older-agent', createdAt: new Date(Date.now() - 7200_000).toISOString() }) + const newer = makeAgent({ id: 'a2', name: 'newer-agent', createdAt: new Date(Date.now() - 1800_000).toISOString() }) + mockListForRadarUseQuery.mockReturnValue({ data: [older, newer], isLoading: false }) + render() + + const tbody = document.querySelector('tbody')! + const rows = within(tbody).getAllByRole('row') + expect(rows[0]).toHaveTextContent('newer-agent') + expect(rows[1]).toHaveTextContent('older-agent') + }) + + it('clicking "Started" column header sorts ascending, clicking again sorts descending', () => { + const older = makeAgent({ id: 'a1', name: 'older-agent', createdAt: new Date(Date.now() - 7200_000).toISOString() }) + const newer = makeAgent({ id: 'a2', name: 'newer-agent', createdAt: new Date(Date.now() - 1800_000).toISOString() }) + mockListForRadarUseQuery.mockReturnValue({ data: [older, newer], isLoading: false }) + render() + + // First click: default is desc (newest first), clicking toggles to asc (oldest first) + fireEvent.click(screen.getByRole('columnheader', { name: /started/i })) + const rowsAsc = within(document.querySelector('tbody')!).getAllByRole('row') + expect(rowsAsc[0]).toHaveTextContent('older-agent') + expect(rowsAsc[1]).toHaveTextContent('newer-agent') + + // Second click: re-query header (text content changed to '▲'), toggle back to desc + fireEvent.click(screen.getByRole('columnheader', { name: /started/i })) + const rowsDesc = within(document.querySelector('tbody')!).getAllByRole('row') + expect(rowsDesc[0]).toHaveTextContent('newer-agent') + expect(rowsDesc[1]).toHaveTextContent('older-agent') + }) + + it('agent name cell renders a Link to /agents with selected param', () => { + const agent = makeAgent({ id: 'agent-xyz', name: 'test-agent' }) + mockListForRadarUseQuery.mockReturnValue({ data: [agent], isLoading: false }) + render() + + const link = screen.getByRole('link', { name: 'test-agent' }) + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', expect.stringContaining('/agents')) + expect(link).toHaveAttribute('href', expect.stringContaining('agent-xyz')) + }) + + it('non-zero metric cell has cursor-pointer class; zero cell does not', () => { + mockListForRadarUseQuery.mockReturnValue({ + data: [makeAgent({ id: 'a1', questionsCount: 5 })], + isLoading: false, + }) + render() + + // Find all cells with text "5" — the non-zero questions cell + const tbody = document.querySelector('tbody')! + const cells = tbody.querySelectorAll('td') + + // Find the cell containing "5" (questionsCount) + const nonZeroCell = Array.from(cells).find(cell => cell.textContent === '5') + expect(nonZeroCell).toBeTruthy() + expect(nonZeroCell!.className).toContain('cursor-pointer') + + // Find a zero cell (messagesCount=0) + const zeroCell = Array.from(cells).find(cell => cell.textContent === '0' && !cell.className.includes('cursor-pointer')) + expect(zeroCell).toBeTruthy() + }) + + it('selecting mode filter calls navigate with mode param', () => { + mockListForRadarUseQuery.mockReturnValue({ data: [], isLoading: false }) + render() + + const selects = screen.getAllByRole('combobox') + // Mode select is the 4th select (timeRange, status, initiative, mode) + const modeSelect = selects[3] + fireEvent.change(modeSelect, { target: { value: 'execute' } }) + + expect(mockNavigate).toHaveBeenCalledWith( + expect.objectContaining({ + search: expect.any(Function), + }) + ) + + // Call the search function to verify the result + const call = mockNavigate.mock.calls[0][0] + const result = call.search({ timeRange: '24h', status: 'all', initiativeId: undefined, mode: 'all' }) + expect(result).toMatchObject({ mode: 'execute' }) + }) + + it('selecting status filter calls navigate with status param', () => { + mockListForRadarUseQuery.mockReturnValue({ data: [], isLoading: false }) + render() + + const selects = screen.getAllByRole('combobox') + // Status select is the 2nd select + const statusSelect = selects[1] + fireEvent.change(statusSelect, { target: { value: 'running' } }) + + expect(mockNavigate).toHaveBeenCalledWith( + expect.objectContaining({ + search: expect.any(Function), + }) + ) + + const call = mockNavigate.mock.calls[0][0] + const result = call.search({ timeRange: '24h', status: 'all', initiativeId: undefined, mode: 'all' }) + expect(result).toMatchObject({ status: 'running' }) + }) + + it('empty state shown when agents returns []', () => { + mockListForRadarUseQuery.mockReturnValue({ data: [], isLoading: false }) + render() + + expect(screen.getByText('No agent activity in this time period')).toBeInTheDocument() + }) + + it('loading skeleton shown when isLoading is true', () => { + mockListForRadarUseQuery.mockReturnValue({ data: undefined, isLoading: true }) + render() + + const skeletons = document.querySelectorAll('.animate-pulse') + expect(skeletons.length).toBeGreaterThanOrEqual(5) + }) +}) + +describe('AppLayout - Radar nav item', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('"Radar" appears in AppLayout nav', () => { + mockListAgentsUseQuery.mockReturnValue({ data: [], isLoading: false }) + render( + +
content
+
+ ) + + const radarLink = screen.getByRole('link', { name: 'Radar' }) + expect(radarLink).toBeInTheDocument() + expect(radarLink).toHaveAttribute('href', expect.stringContaining('/radar')) + }) +}) diff --git a/apps/web/src/routes/radar.tsx b/apps/web/src/routes/radar.tsx new file mode 100644 index 0000000..8547b6c --- /dev/null +++ b/apps/web/src/routes/radar.tsx @@ -0,0 +1,327 @@ +import { useState, useMemo } from 'react' +import { createFileRoute, useNavigate, useSearch, Link } from '@tanstack/react-router' +import { trpc } from '@/lib/trpc' +import { useLiveUpdates } from '@/hooks' +import type { LiveUpdateRule } from '@/hooks' +import { Card, CardContent } from '@/components/ui/card' + +type TimeRange = '1h' | '6h' | '24h' | '7d' | 'all' +type StatusFilter = 'all' | 'running' | 'completed' | 'crashed' +type ModeFilter = 'all' | 'execute' | 'discuss' | 'plan' | 'detail' | 'refine' | 'chat' | 'errand' +type SortColumn = + | 'name' + | 'mode' + | 'status' + | 'initiative' + | 'task' + | 'started' + | 'questions' + | 'messages' + | 'subagents' + | 'compactions' + +const VALID_TIME_RANGES: TimeRange[] = ['1h', '6h', '24h', '7d', 'all'] +const VALID_STATUSES: StatusFilter[] = ['all', 'running', 'completed', 'crashed'] +const VALID_MODES: ModeFilter[] = [ + 'all', + 'execute', + 'discuss', + 'plan', + 'detail', + 'refine', + 'chat', + 'errand', +] + +export const Route = createFileRoute('/radar')({ + component: RadarPage, + validateSearch: (search: Record) => ({ + timeRange: VALID_TIME_RANGES.includes(search.timeRange as TimeRange) + ? (search.timeRange as TimeRange) + : '24h', + status: VALID_STATUSES.includes(search.status as StatusFilter) + ? (search.status as StatusFilter) + : 'all', + initiativeId: typeof search.initiativeId === 'string' ? search.initiativeId : undefined, + mode: VALID_MODES.includes(search.mode as ModeFilter) ? (search.mode as ModeFilter) : 'all', + }), +}) + +const RADAR_LIVE_UPDATE_RULES: LiveUpdateRule[] = [ + { prefix: 'agent:waiting', invalidate: ['agent'] }, + { prefix: 'conversation:created', invalidate: ['agent'] }, + { prefix: 'agent:stopped', invalidate: ['agent'] }, + { prefix: 'agent:crashed', invalidate: ['agent'] }, +] + +export function RadarPage() { + const { timeRange, status, initiativeId, mode } = useSearch({ from: '/radar' }) as { + timeRange: TimeRange + status: StatusFilter + initiativeId: string | undefined + mode: ModeFilter + } + const navigate = useNavigate() + + useLiveUpdates(RADAR_LIVE_UPDATE_RULES) + + const { data: agents = [], isLoading } = trpc.agent.listForRadar.useQuery({ + timeRange, + status: status === 'all' ? undefined : status, + initiativeId: initiativeId ?? undefined, + mode: mode === 'all' ? undefined : mode, + }) + + const { data: initiatives = [] } = trpc.listInitiatives.useQuery() + + const [sortState, setSortState] = useState<{ column: SortColumn; direction: 'asc' | 'desc' }>({ + column: 'started', + direction: 'desc', + }) + + function handleSort(column: SortColumn) { + setSortState((prev) => + prev.column === column + ? { column, direction: prev.direction === 'asc' ? 'desc' : 'asc' } + : { column, direction: 'asc' }, + ) + } + + const sortedAgents = useMemo(() => { + return [...agents].sort((a, b) => { + let cmp = 0 + switch (sortState.column) { + case 'name': + cmp = a.name.localeCompare(b.name) + break + case 'mode': + cmp = a.mode.localeCompare(b.mode) + break + case 'status': + cmp = a.status.localeCompare(b.status) + break + case 'initiative': + cmp = (a.initiativeName ?? '').localeCompare(b.initiativeName ?? '') + break + case 'task': + cmp = (a.taskName ?? '').localeCompare(b.taskName ?? '') + break + case 'started': + cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + break + case 'questions': + cmp = a.questionsCount - b.questionsCount + break + case 'messages': + cmp = a.messagesCount - b.messagesCount + break + case 'subagents': + cmp = a.subagentsCount - b.subagentsCount + break + case 'compactions': + cmp = a.compactionsCount - b.compactionsCount + break + } + return sortState.direction === 'asc' ? cmp : -cmp + }) + }, [agents, sortState]) + + const totalQuestions = agents.reduce((sum, a) => sum + a.questionsCount, 0) + const totalMessages = agents.reduce((sum, a) => sum + a.messagesCount, 0) + const totalSubagents = agents.reduce((sum, a) => sum + a.subagentsCount, 0) + const totalCompactions = agents.reduce((sum, a) => sum + a.compactionsCount, 0) + + function sortIndicator(column: SortColumn) { + if (sortState.column !== column) return null + return sortState.direction === 'asc' ? ' ▲' : ' ▼' + } + + function SortableTh({ + column, + label, + className, + }: { + column: SortColumn + label: string + className?: string + }) { + return ( + handleSort(column)} + > + {label} + {sortIndicator(column)} + + ) + } + + function MetricCell({ value }: { value: number }) { + if (value > 0) { + return ( + { + // TODO: open drilldown dialog — wired in Phase 4 (Dialog Integration) + }} + > + {value} + + ) + } + return 0 + } + + return ( +
+

Radar

+ + {/* Summary stat cards */} +
+ + +

{totalQuestions}

+

Total Questions Asked

+
+
+ + +

{totalMessages}

+

Total Inter-Agent Messages

+
+
+ + +

{totalSubagents}

+

Total Subagent Spawns

+
+
+ + +

{totalCompactions}

+

Total Compaction Events

+
+
+
+ + {/* Filter bar */} +
+ + + + + + + +
+ + {/* Empty state */} + {!isLoading && agents.length === 0 && ( +

No agent activity in this time period

+ )} + + {/* Agent activity table */} + {(isLoading || agents.length > 0) && ( + + + + + + + + + + + + + + + + + {isLoading + ? Array.from({ length: 5 }).map((_, i) => ( + + + + )) + : sortedAgents.map((agent) => ( + + + + + + + + + + + + + ))} + +
+
+
+ + {agent.name} + + {agent.mode}{agent.status}{agent.initiativeName ?? '—'}{agent.taskName ?? '—'} + {new Date(agent.createdAt).toLocaleString()} +
+ )} +
+ ) +}