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"),