diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index 81fe6e3..411612e 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as SettingsRouteImport } from './routes/settings'
+import { Route as RadarRouteImport } from './routes/radar'
import { Route as InboxRouteImport } from './routes/inbox'
import { Route as HqRouteImport } from './routes/hq'
import { Route as AgentsRouteImport } from './routes/agents'
@@ -25,6 +26,11 @@ const SettingsRoute = SettingsRouteImport.update({
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
+const RadarRoute = RadarRouteImport.update({
+ id: '/radar',
+ path: '/radar',
+ getParentRoute: () => rootRouteImport,
+} as any)
const InboxRoute = InboxRouteImport.update({
id: '/inbox',
path: '/inbox',
@@ -76,6 +82,7 @@ export interface FileRoutesByFullPath {
'/agents': typeof AgentsRoute
'/hq': typeof HqRoute
'/inbox': typeof InboxRoute
+ '/radar': typeof RadarRoute
'/settings': typeof SettingsRouteWithChildren
'/initiatives/$id': typeof InitiativesIdRoute
'/settings/health': typeof SettingsHealthRoute
@@ -88,6 +95,7 @@ export interface FileRoutesByTo {
'/agents': typeof AgentsRoute
'/hq': typeof HqRoute
'/inbox': typeof InboxRoute
+ '/radar': typeof RadarRoute
'/initiatives/$id': typeof InitiativesIdRoute
'/settings/health': typeof SettingsHealthRoute
'/settings/projects': typeof SettingsProjectsRoute
@@ -100,6 +108,7 @@ export interface FileRoutesById {
'/agents': typeof AgentsRoute
'/hq': typeof HqRoute
'/inbox': typeof InboxRoute
+ '/radar': typeof RadarRoute
'/settings': typeof SettingsRouteWithChildren
'/initiatives/$id': typeof InitiativesIdRoute
'/settings/health': typeof SettingsHealthRoute
@@ -114,6 +123,7 @@ export interface FileRouteTypes {
| '/agents'
| '/hq'
| '/inbox'
+ | '/radar'
| '/settings'
| '/initiatives/$id'
| '/settings/health'
@@ -126,6 +136,7 @@ export interface FileRouteTypes {
| '/agents'
| '/hq'
| '/inbox'
+ | '/radar'
| '/initiatives/$id'
| '/settings/health'
| '/settings/projects'
@@ -137,6 +148,7 @@ export interface FileRouteTypes {
| '/agents'
| '/hq'
| '/inbox'
+ | '/radar'
| '/settings'
| '/initiatives/$id'
| '/settings/health'
@@ -150,6 +162,7 @@ export interface RootRouteChildren {
AgentsRoute: typeof AgentsRoute
HqRoute: typeof HqRoute
InboxRoute: typeof InboxRoute
+ RadarRoute: typeof RadarRoute
SettingsRoute: typeof SettingsRouteWithChildren
InitiativesIdRoute: typeof InitiativesIdRoute
InitiativesIndexRoute: typeof InitiativesIndexRoute
@@ -164,6 +177,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/radar': {
+ id: '/radar'
+ path: '/radar'
+ fullPath: '/radar'
+ preLoaderRoute: typeof RadarRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/inbox': {
id: '/inbox'
path: '/inbox'
@@ -251,6 +271,7 @@ const rootRouteChildren: RootRouteChildren = {
AgentsRoute: AgentsRoute,
HqRoute: HqRoute,
InboxRoute: InboxRoute,
+ RadarRoute: RadarRoute,
SettingsRoute: SettingsRouteWithChildren,
InitiativesIdRoute: InitiativesIdRoute,
InitiativesIndexRoute: InitiativesIndexRoute,
diff --git a/apps/web/src/routes/__tests__/radar.test.tsx b/apps/web/src/routes/__tests__/radar.test.tsx
deleted file mode 100644
index 01f110e..0000000
--- a/apps/web/src/routes/__tests__/radar.test.tsx
+++ /dev/null
@@ -1,451 +0,0 @@
-// @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'
-
-vi.mock('@/components/radar/CompactionEventsDialog', () => ({
- CompactionEventsDialog: ({ open, agentId, agentName, isAgentRunning, onOpenChange }: any) =>
- open ? (
-
-
-
- ) : null,
-}))
-vi.mock('@/components/radar/SubagentSpawnsDialog', () => ({
- SubagentSpawnsDialog: ({ open, agentId, agentName, isAgentRunning, onOpenChange }: any) =>
- open ? (
-
-
-
- ) : null,
-}))
-vi.mock('@/components/radar/QuestionsAskedDialog', () => ({
- QuestionsAskedDialog: ({ open, agentId, agentName, isAgentRunning, onOpenChange }: any) =>
- open ? (
-
-
-
- ) : null,
-}))
-vi.mock('@/components/radar/InterAgentMessagesDialog', () => ({
- InterAgentMessagesDialog: ({ open, agentId, agentName, isAgentRunning, onOpenChange }: any) =>
- open ? (
-
-
-
- ) : null,
-}))
-
-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('dialog integration', () => {
- const alphaAgent = {
- id: 'agent-alpha-id',
- name: 'agent-alpha',
- mode: 'execute',
- status: 'running',
- initiativeId: null,
- initiativeName: null,
- taskId: null,
- taskName: null,
- createdAt: new Date(Date.now() - 3600_000).toISOString(),
- questionsCount: 3,
- messagesCount: 2,
- subagentsCount: 1,
- compactionsCount: 4,
- }
- const betaAgent = {
- id: 'agent-beta-id',
- name: 'agent-beta',
- mode: 'execute',
- status: 'stopped',
- initiativeId: null,
- initiativeName: null,
- taskId: null,
- taskName: null,
- createdAt: new Date(Date.now() - 7200_000).toISOString(),
- questionsCount: 0,
- messagesCount: 0,
- subagentsCount: 0,
- compactionsCount: 0,
- }
-
- beforeEach(() => {
- mockListForRadarUseQuery.mockReturnValue({ data: [alphaAgent, betaAgent], isLoading: false })
- })
-
- it('clicking a non-zero Compactions cell opens CompactionEventsDialog with correct agentId and agentName', async () => {
- render()
- const cell = screen.getByTestId(`cell-compactions-agent-alpha-id`)
- fireEvent.click(cell)
- const dialog = await screen.findByTestId('compaction-dialog')
- expect(dialog).toBeInTheDocument()
- expect(dialog).toHaveAttribute('data-agent-name', 'agent-alpha')
- expect(dialog).toHaveAttribute('data-agent-id', 'agent-alpha-id')
- })
-
- it('clicking a non-zero Subagents cell opens SubagentSpawnsDialog', async () => {
- render()
- const cell = screen.getByTestId(`cell-subagents-agent-alpha-id`)
- fireEvent.click(cell)
- const dialog = await screen.findByTestId('subagents-dialog')
- expect(dialog).toBeInTheDocument()
- expect(dialog).toHaveAttribute('data-agent-name', 'agent-alpha')
- expect(dialog).toHaveAttribute('data-agent-id', 'agent-alpha-id')
- })
-
- it('clicking a non-zero Questions cell opens QuestionsAskedDialog', async () => {
- render()
- const cell = screen.getByTestId(`cell-questions-agent-alpha-id`)
- fireEvent.click(cell)
- const dialog = await screen.findByTestId('questions-dialog')
- expect(dialog).toBeInTheDocument()
- expect(dialog).toHaveAttribute('data-agent-name', 'agent-alpha')
- expect(dialog).toHaveAttribute('data-agent-id', 'agent-alpha-id')
- })
-
- it('clicking a non-zero Messages cell opens InterAgentMessagesDialog', async () => {
- render()
- const cell = screen.getByTestId(`cell-messages-agent-alpha-id`)
- fireEvent.click(cell)
- const dialog = await screen.findByTestId('messages-dialog')
- expect(dialog).toBeInTheDocument()
- expect(dialog).toHaveAttribute('data-agent-name', 'agent-alpha')
- expect(dialog).toHaveAttribute('data-agent-id', 'agent-alpha-id')
- })
-
- it('dialog closes when onOpenChange(false) fires', async () => {
- render()
- const cell = screen.getByTestId(`cell-compactions-agent-alpha-id`)
- fireEvent.click(cell)
- const dialog = await screen.findByTestId('compaction-dialog')
- expect(dialog).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: 'close' }))
- expect(screen.queryByTestId('compaction-dialog')).not.toBeInTheDocument()
- })
-
- it('zero metric cells do not open any dialog when clicked', () => {
- render()
- // agent-beta has all zeros — click each zero metric cell
- fireEvent.click(screen.getByTestId('cell-compactions-agent-beta-id'))
- fireEvent.click(screen.getByTestId('cell-subagents-agent-beta-id'))
- fireEvent.click(screen.getByTestId('cell-questions-agent-beta-id'))
- fireEvent.click(screen.getByTestId('cell-messages-agent-beta-id'))
- expect(screen.queryByTestId('compaction-dialog')).not.toBeInTheDocument()
- expect(screen.queryByTestId('subagents-dialog')).not.toBeInTheDocument()
- expect(screen.queryByTestId('questions-dialog')).not.toBeInTheDocument()
- expect(screen.queryByTestId('messages-dialog')).not.toBeInTheDocument()
- })
-
- it('passes isAgentRunning=true when the agent status is "running"', async () => {
- render()
- const cell = screen.getByTestId(`cell-compactions-agent-alpha-id`)
- fireEvent.click(cell)
- const dialog = await screen.findByTestId('compaction-dialog')
- expect(dialog).toHaveAttribute('data-is-running', 'true')
- })
-
- it('passes isAgentRunning=false when the agent status is "stopped"', async () => {
- // Use a stopped agent with non-zero compactions
- const stoppedAgent = { ...betaAgent, id: 'stopped-agent-id', name: 'stopped-agent', compactionsCount: 2, status: 'stopped' }
- mockListForRadarUseQuery.mockReturnValue({ data: [stoppedAgent], isLoading: false })
- render()
- const cell = screen.getByTestId('cell-compactions-stopped-agent-id')
- fireEvent.click(cell)
- const dialog = await screen.findByTestId('compaction-dialog')
- expect(dialog).toHaveAttribute('data-is-running', 'false')
- })
- })
-})
-
-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/vite.config.ts b/apps/web/vite.config.ts
index 6153961..e1eba53 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -4,7 +4,13 @@ import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
- plugins: [TanStackRouterVite({ autoCodeSplitting: true }), react()],
+ plugins: [
+ TanStackRouterVite({
+ autoCodeSplitting: true,
+ routeFileIgnorePattern: '__tests__',
+ }),
+ react(),
+ ],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),