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:
@@ -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
|
||||
|
||||
299
apps/web/src/routes/__tests__/radar.test.tsx
Normal file
299
apps/web/src/routes/__tests__/radar.test.tsx
Normal 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'))
|
||||
})
|
||||
})
|
||||
327
apps/web/src/routes/radar.tsx
Normal file
327
apps/web/src/routes/radar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user