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 <noreply@anthropic.com>
This commit is contained in:
Lukas May
2026-03-06 20:10:39 +01:00
parent 5598e1c10f
commit b860bc100d
3 changed files with 627 additions and 0 deletions

View File

@@ -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

View File

@@ -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<string, string>
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 <a href={href}>{content}</a>
},
}))
// Import after mocks
import { RadarPage } from '../radar'
import { AppLayout } from '../../layouts/AppLayout'
function makeAgent(overrides?: Partial<AgentRadarRow>): 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(<RadarPage />)
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(<RadarPage />)
// 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(<RadarPage />)
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(<RadarPage />)
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(<RadarPage />)
// 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(<RadarPage />)
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(<RadarPage />)
// 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(<RadarPage />)
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(<RadarPage />)
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(<RadarPage />)
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(<RadarPage />)
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(
<AppLayout connectionState="connected">
<div>content</div>
</AppLayout>
)
const radarLink = screen.getByRole('link', { name: 'Radar' })
expect(radarLink).toBeInTheDocument()
expect(radarLink).toHaveAttribute('href', expect.stringContaining('/radar'))
})
})

View File

@@ -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<string, unknown>) => ({
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 (
<th
className={`cursor-pointer select-none whitespace-nowrap px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground ${className ?? ''}`}
onClick={() => handleSort(column)}
>
{label}
{sortIndicator(column)}
</th>
)
}
function MetricCell({ value }: { value: number }) {
if (value > 0) {
return (
<td
className="cursor-pointer hover:bg-muted/50 text-right px-3 py-2"
onClick={() => {
// TODO: open drilldown dialog — wired in Phase 4 (Dialog Integration)
}}
>
{value}
</td>
)
}
return <td className="text-muted-foreground text-right px-3 py-2">0</td>
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Radar</h1>
{/* Summary stat cards */}
<div className="grid grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<p className="text-3xl font-bold">{totalQuestions}</p>
<p className="text-sm text-muted-foreground">Total Questions Asked</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-3xl font-bold">{totalMessages}</p>
<p className="text-sm text-muted-foreground">Total Inter-Agent Messages</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-3xl font-bold">{totalSubagents}</p>
<p className="text-sm text-muted-foreground">Total Subagent Spawns</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-3xl font-bold">{totalCompactions}</p>
<p className="text-sm text-muted-foreground">Total Compaction Events</p>
</CardContent>
</Card>
</div>
{/* Filter bar */}
<div className="flex gap-4 items-center">
<select
value={timeRange}
onChange={(e) => {
const val = e.target.value as TimeRange
navigate({ search: (prev) => ({ ...prev, timeRange: val }) })
}}
>
<option value="1h">Last 1h</option>
<option value="6h">Last 6h</option>
<option value="24h">Last 24h</option>
<option value="7d">Last 7d</option>
<option value="all">All time</option>
</select>
<select
value={status}
onChange={(e) => {
const val = e.target.value as StatusFilter
navigate({ search: (prev) => ({ ...prev, status: val }) })
}}
>
<option value="all">All</option>
<option value="running">Running</option>
<option value="completed">Completed</option>
<option value="crashed">Crashed</option>
</select>
<select
value={initiativeId ?? ''}
onChange={(e) => {
const val = e.target.value
navigate({
search: (prev) => ({ ...prev, initiativeId: val === '' ? undefined : val }),
})
}}
>
<option value="">All Initiatives</option>
{initiatives.map((ini: { id: string; name: string }) => (
<option key={ini.id} value={ini.id}>
{ini.name}
</option>
))}
</select>
<select
value={mode}
onChange={(e) => {
const val = e.target.value as ModeFilter
navigate({ search: (prev) => ({ ...prev, mode: val }) })
}}
>
<option value="all">All</option>
<option value="execute">Execute</option>
<option value="discuss">Discuss</option>
<option value="plan">Plan</option>
<option value="detail">Detail</option>
<option value="refine">Refine</option>
<option value="chat">Chat</option>
<option value="errand">Errand</option>
</select>
</div>
{/* Empty state */}
{!isLoading && agents.length === 0 && (
<p className="text-center text-muted-foreground">No agent activity in this time period</p>
)}
{/* Agent activity table */}
{(isLoading || agents.length > 0) && (
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-border">
<SortableTh column="name" label="Agent Name" />
<SortableTh column="mode" label="Mode" />
<SortableTh column="status" label="Status" />
<SortableTh column="initiative" label="Initiative" />
<SortableTh column="task" label="Task" />
<SortableTh column="started" label="Started" />
<SortableTh column="questions" label="Questions" className="text-right" />
<SortableTh column="messages" label="Messages" className="text-right" />
<SortableTh column="subagents" label="Subagents" className="text-right" />
<SortableTh column="compactions" label="Compactions" className="text-right" />
</tr>
</thead>
<tbody>
{isLoading
? Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
<td colSpan={10} className="px-3 py-2">
<div className="h-4 bg-muted rounded animate-pulse" />
</td>
</tr>
))
: sortedAgents.map((agent) => (
<tr key={agent.id} className="border-b border-border/50 hover:bg-muted/30">
<td className="px-3 py-2">
<Link to="/agents" search={{ selected: agent.id }}>
{agent.name}
</Link>
</td>
<td className="px-3 py-2">{agent.mode}</td>
<td className="px-3 py-2">{agent.status}</td>
<td className="px-3 py-2">{agent.initiativeName ?? '—'}</td>
<td className="px-3 py-2">{agent.taskName ?? '—'}</td>
<td className="px-3 py-2">
{new Date(agent.createdAt).toLocaleString()}
</td>
<MetricCell value={agent.questionsCount} />
<MetricCell value={agent.messagesCount} />
<MetricCell value={agent.subagentsCount} />
<MetricCell value={agent.compactionsCount} />
</tr>
))}
</tbody>
</table>
)}
</div>
)
}